check-versions.mjs reported "12 SKIP, exit 0" on a fresh clone with no sibling plugin repos — a run that verified nothing was indistinguishable from a clean run. Any SKIP now fails the gate by default (--allow-skip opts in explicitly), and the summary always reports "verified N/M". Also adds a homepage-resolves check on plugin.json (dead-link ERROR, verified live against voyage's stale pre-polyrepo URL); the field stays optional since 11 of 12 plugins don't carry it yet. gateOutcome is split out as a pure function so both directions (all-SKIP fails, fully-verified-clean still passes) are unit-tested without needing a real sibling-repo layout on disk.
356 lines
18 KiB
JavaScript
356 lines
18 KiB
JavaScript
#!/usr/bin/env node
|
||
// Marketplace version-consistency gate.
|
||
//
|
||
// For every plugin in catalog/.claude-plugin/marketplace.json, compare its catalog `ref`
|
||
// against the plugin repo found as a sibling directory (../<name>):
|
||
// - catalog ref must point at an existing git tag → else ERROR (install-breaking)
|
||
// - plugin.json version must equal the README version-badge → else ERROR (internal)
|
||
// - catalog README per-plugin label must equal catalog ref → else ERROR (doc misstates install)
|
||
// - catalog ref should equal plugin.json version → else WARN (catalog lags / unreleased bump)
|
||
// - plugin.json homepage, if present, must resolve → else ERROR (dead link); field is OPTIONAL
|
||
// - sibling repo missing → SKIP
|
||
//
|
||
// SKIP is UNVERIFIED, not clean — a plugin that could not be checked at all is not the
|
||
// same thing as a plugin that was checked and found fine. A run with any SKIP therefore
|
||
// exits 1 by default (fixes: 2026-08-18 a fresh clone with zero sibling repos printed
|
||
// "12 SKIP, exit 0" — a run that verified NOTHING read as green). Pass --allow-skip to
|
||
// accept partial verification explicitly; the summary line always reports "verified N/M".
|
||
//
|
||
// Exit 1 if any ERROR, any SKIP (unless --allow-skip), or any WARN under --strict; 0 otherwise.
|
||
// Usage: node scripts/check-versions.mjs [--strict] [--allow-skip]
|
||
import { readFileSync, existsSync } from 'node:fs';
|
||
import { execFileSync } from 'node:child_process';
|
||
import { join, dirname } from 'node:path';
|
||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||
|
||
export function normalizeVersion(v) {
|
||
return String(v).trim().replace(/^v/, '');
|
||
}
|
||
|
||
export function extractBadgeVersion(readmeText) {
|
||
const m = /badge\/version-(\d+\.\d+\.\d+)/.exec(readmeText || '');
|
||
return m ? m[1] : null;
|
||
}
|
||
|
||
// Read the catalog README's per-plugin version label, e.g.
|
||
// ### [Config-Audit](https://.../open/config-audit) `v5.7.0`
|
||
// Matches the heading line by its `/open/<name>)` link and returns the FIRST `vX.Y.Z`
|
||
// token on it (so a trailing `🇳🇴 Norwegian`-style badge is ignored). null if no entry.
|
||
export function extractCatalogLabel(readmeText, name) {
|
||
for (const line of String(readmeText || '').split('\n')) {
|
||
if (line.includes(`/open/${name})`)) {
|
||
const m = /`v(\d+\.\d+\.\d+)`/.exec(line);
|
||
return m ? m[1] : null;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// --- stat-badge mirroring -------------------------------------------------
|
||
//
|
||
// The catalog restates each plugin's counts in a per-plugin stat line
|
||
// ("7 agents · 16 scanners · 21 commands · 1410 tests · [Full documentation →]").
|
||
// Those numbers rot: measured 2026-08-02, config-audit's line said 1410 tests
|
||
// while the plugin's own badge said 1441. This mirrors catalog line -> plugin badge.
|
||
//
|
||
// The rule is PER-AXIS, not per-plugin. An axis is gated only when the plugin
|
||
// carries a badge for it; a badge-less axis is skipped silently. That is not a
|
||
// concession to voyage (the one plugin with no stat badges at all) — re-measured
|
||
// 2026-08-04 at the pinned refs, 15 of the 42 axis-claims across 7 of the 12
|
||
// plugins are badge-less, so a per-plugin exception list would have had to name
|
||
// most of the marketplace and be edited by hand forever. The cost is stated
|
||
// plainly: those 15 numbers stay ungated and need a human pass whenever a ref
|
||
// moves — repo-mailbox's `6 CLI scripts` was 8 when measured, and it badges both
|
||
// of its axes now, so they are gated as of v0.20.2. (Its selftest-check figure is
|
||
// the standing proof that ungated numbers rot: the catalog said 251 while three
|
||
// records of the true value disagree — see CLAUDE.md. Not re-measured.)
|
||
|
||
const AXIS_SYNONYM = { 'reference doc': 'reference', 'knowledge doc': 'reference', doc: 'reference' };
|
||
|
||
// Fold a badge label or a stat-line noun onto a shared axis name.
|
||
export function normalizeAxis(raw) {
|
||
let s = String(raw || '').replace(/--/g, '-').replace(/%20/g, ' ').replace(/_/g, ' ')
|
||
.toLowerCase().replace(/\s+/g, ' ').trim();
|
||
s = s.replace(/\s+files?$/, '');
|
||
const words = s.split(' ');
|
||
const last = words[words.length - 1];
|
||
if (last.length > 3 && last.endsWith('s') && !last.endsWith('ss')) words[words.length - 1] = last.slice(0, -1);
|
||
s = words.join(' ');
|
||
return AXIS_SYNONYM[s] || s;
|
||
}
|
||
|
||
// Plugin README -> Map(axis -> count). Only badges whose VALUE is an integer are
|
||
// stat axes; version/platform/license carry text and are ignored. Shields escapes
|
||
// a literal hyphen as `--`, so the label/value/color split must ignore doubled ones.
|
||
export function extractStatBadges(readmeText) {
|
||
const out = new Map();
|
||
for (const m of String(readmeText || '').matchAll(/img\.shields\.io\/badge\/([^)\s]+)/g)) {
|
||
const parts = m[1].split(/(?<!-)-(?!-)/);
|
||
if (parts.length < 3) continue;
|
||
if (!/^\d+$/.test(parts[1])) continue;
|
||
out.set(normalizeAxis(parts[0]), Number(parts[1]));
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// Pull "<n> <axis>" pairs out of one `·`-separated segment, including any
|
||
// parenthetical aside ("5 skills (389 docs)" is two axes; "(+1 helper)" is one).
|
||
function readStatSegment(segment, out) {
|
||
const asides = [...segment.matchAll(/\(([^)]*)\)/g)].map(a => a[1]);
|
||
const main = segment.replace(/\([^)]*\)/g, ' ');
|
||
for (const piece of [main, ...asides]) {
|
||
const m = /(\d+)(\+?)\s+([A-Za-z][A-Za-z -]*)/.exec(piece);
|
||
if (!m) continue;
|
||
const axis = normalizeAxis(m[3]);
|
||
if (!axis || out.has(axis)) continue;
|
||
out.set(axis, m[2] === '+' ? { atLeast: Number(m[1]) } : Number(m[1]));
|
||
}
|
||
}
|
||
|
||
// Which copy of the plugin README the stat mirror must read.
|
||
//
|
||
// The catalog documents what INSTALLS, and what installs is the pinned `ref` — not
|
||
// whatever the sibling working tree happens to hold. Measured live 2026-08-02:
|
||
// llm-security had committed past its v7.8.3 tag (scanners 23->22, tests 2013->2034)
|
||
// without bumping the version, so reading the working tree reported the catalog stale
|
||
// while the catalog was correct about v7.8.3. Fall back to the working tree only when
|
||
// the ref cannot be read at all — a ref that resolves to nothing is already its own
|
||
// ERROR (check 1), so this fallback never hides a dangling ref.
|
||
//
|
||
// Reading the ref decides COVERAGE too, not just values. Measured 2026-08-04:
|
||
// graceful-handoff had dropped its `tests-30` and `hooks-0` badges on main (13e2972,
|
||
// 5e17409) without releasing, so the working tree says the catalog's `30 tests` is
|
||
// ungated while at v3.1.0 it is badged and gated. The failure directions are opposite
|
||
// — the llm-security case above makes a correct catalog look stale — so neither one
|
||
// alone would have caught this.
|
||
export function pickStatSource({ atRef, atWorktree }) {
|
||
return atRef !== null && atRef !== undefined ? atRef : (atWorktree ?? null);
|
||
}
|
||
|
||
// Catalog README -> Map(axis -> count | {atLeast}). Reads ONLY the stat line of the
|
||
// named plugin's block, so prose numbers above it can never be mistaken for counts.
|
||
export function extractCatalogStats(readmeText, name) {
|
||
const lines = String(readmeText || '').split('\n');
|
||
const out = new Map();
|
||
let inBlock = false;
|
||
for (const line of lines) {
|
||
if (line.startsWith('### [')) inBlock = line.includes(`/open/${name})`);
|
||
if (!inBlock || !line.includes('[Full documentation')) continue;
|
||
for (const seg of line.split('[Full documentation')[0].split('·')) readStatSegment(seg, out);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// Pure classifier — all I/O is resolved into the input shape before this is called.
|
||
// tags === null means "repo not inspected" (missing locally); [] means "no tags".
|
||
export function classifyPlugin({ name, catalogRef, pluginVersion, readmeBadge, tags, catalogLabel = null, statBadges = null, catalogStats = null, homepage = null, homepageReachable = null }) {
|
||
if (pluginVersion === null && tags === null) {
|
||
return { name, status: 'SKIP', findings: [{ level: 'SKIP', msg: 'plugin repo not found locally — cannot verify' }] };
|
||
}
|
||
|
||
const findings = [];
|
||
|
||
if (pluginVersion === null) {
|
||
findings.push({ level: 'ERROR', msg: 'plugin.json version not found or unparseable' });
|
||
}
|
||
|
||
// 1. dangling ref — catalog points at a tag that does not exist in the plugin repo
|
||
if (tags !== null && !tags.includes(catalogRef)) {
|
||
findings.push({ level: 'ERROR', msg: `catalog ref ${catalogRef} has no matching git tag in the plugin repo (install-breaking)` });
|
||
}
|
||
|
||
// 2. internal consistency — plugin.json version vs README version-badge
|
||
if (pluginVersion !== null && readmeBadge !== null && pluginVersion !== readmeBadge) {
|
||
findings.push({ level: 'ERROR', msg: `plugin.json version ${pluginVersion} != README version-badge ${readmeBadge}` });
|
||
}
|
||
|
||
// 3. catalog README label vs catalog ref — the human-facing doc must match what installs.
|
||
// Unlike ref-vs-plugin.json, there is no legitimate transient state where these differ.
|
||
if (catalogLabel !== null && normalizeVersion(catalogRef) !== catalogLabel) {
|
||
findings.push({ level: 'ERROR', msg: `catalog README label v${catalogLabel} != catalog ref ${catalogRef} (README misstates the installed version)` });
|
||
}
|
||
|
||
// 4. catalog ref vs plugin.json version
|
||
if (pluginVersion !== null && normalizeVersion(catalogRef) !== pluginVersion) {
|
||
const releasedTag = tags !== null && tags.includes('v' + pluginVersion);
|
||
const reason = releasedTag
|
||
? `released version v${pluginVersion} exists as a tag — bump catalog ref`
|
||
: `no tag for v${pluginVersion} — unreleased bump, or a release that was never tagged`;
|
||
findings.push({ level: 'WARN', msg: `catalog ref ${catalogRef} != plugin.json version ${pluginVersion} (${reason})` });
|
||
}
|
||
|
||
// 5. catalog stat line vs the plugin's own stat badges — per-axis, badge-gated.
|
||
// An axis the plugin does not badge is SKIPPED (no claim to mirror). An axis the
|
||
// badge carries but the catalog does not restate is likewise nothing: the catalog
|
||
// chooses what to show. Only a stated number contradicting a badged one is a defect.
|
||
if (statBadges !== null && catalogStats !== null) {
|
||
for (const [axis, stated] of catalogStats) {
|
||
if (!statBadges.has(axis)) continue;
|
||
const badge = statBadges.get(axis);
|
||
const approx = stated !== null && typeof stated === 'object';
|
||
const bad = approx ? badge < stated.atLeast : badge !== stated;
|
||
if (bad) {
|
||
const shown = approx ? `${stated.atLeast}+` : String(stated);
|
||
findings.push({
|
||
level: 'ERROR',
|
||
msg: `catalog says ${shown} ${axis} but the plugin's badge says ${badge} (catalog stat line is stale)`,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// 6. plugin.json homepage must resolve when present — the first link an agent follows
|
||
// from a manifest. The field stays OPTIONAL (measured 2026-08-18: 11 of 12 plugins omit
|
||
// it) — a deliberate policy call, not an oversight. Making it required would flip those
|
||
// 11 to ERROR and, via release-plugin.mjs's ERROR-set pre-flight (catalog-wide), block
|
||
// every release in the marketplace until each of those 11 sibling repos re-tags. That is
|
||
// a marketplace-wide freeze delivered by a script fix, not a gate improvement.
|
||
// `homepageReachable === null` means "not checked" (e.g. network unreachable) and must
|
||
// NOT read as either pass or fail.
|
||
if (homepage !== null && homepageReachable === false) {
|
||
findings.push({ level: 'ERROR', msg: `plugin.json homepage ${homepage} does not resolve (dead link)` });
|
||
}
|
||
|
||
const status = findings.some(f => f.level === 'ERROR') ? 'ERROR'
|
||
: findings.some(f => f.level === 'WARN') ? 'WARN'
|
||
: 'OK';
|
||
if (findings.length === 0) findings.push({ level: 'OK', msg: `consistent at ${pluginVersion}` });
|
||
return { name, status, findings };
|
||
}
|
||
|
||
// Read one file as it exists AT a git ref, without touching the working tree.
|
||
// null when the ref or the path does not resolve there.
|
||
function gitShow(repoDir, ref, path) {
|
||
if (!ref) return null;
|
||
try {
|
||
return execFileSync('git', ['-C', repoDir, 'show', `${ref}:${path}`], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function gitTags(repoDir) {
|
||
try {
|
||
const out = execFileSync('git', ['-C', repoDir, 'tag', '--list', 'v*'], { encoding: 'utf8' });
|
||
return out.split('\n').map(s => s.trim()).filter(Boolean);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// Synchronous on purpose (curl via execFileSync, not fetch): inspectPlugin/runGate must
|
||
// stay synchronous because release-plugin.mjs's applyRelease calls `io.runGate(catalogDir)`
|
||
// without awaiting it. Turning runGate async would make that call site silently receive a
|
||
// Promise — preflightErrors reads `gateResult?.results ?? []`, so a Promise (no `.results`)
|
||
// resolves to an empty error list and the pre-flight gate stops blocking ANY release,
|
||
// forever, without a single visible failure. That is the exact "unmeasured reads as green"
|
||
// defect this order exists to fix, one level away — so this stays synchronous.
|
||
// Returns true/false only on a definitive HTTP response; null ("not checked") on any
|
||
// network failure — timeouts and DNS errors must not read as a confirmed dead link.
|
||
function checkHomepage(url) {
|
||
if (!url) return null;
|
||
try {
|
||
const out = execFileSync(
|
||
'curl', ['-s', '-o', '/dev/null', '-w', '%{http_code}', '--max-time', '5', '-L', url],
|
||
{ encoding: 'utf8' },
|
||
).trim();
|
||
const code = Number(out);
|
||
if (!Number.isFinite(code) || code === 0) return null;
|
||
return code < 400;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// I/O shell — resolve one plugin's observed state from disk, then classify.
|
||
export function inspectPlugin(catalogDir, plugin) {
|
||
const name = plugin.name;
|
||
const catalogRef = plugin.source?.ref ?? null;
|
||
const repoDir = join(catalogDir, '..', name);
|
||
|
||
if (!existsSync(repoDir)) {
|
||
return classifyPlugin({ name, catalogRef, pluginVersion: null, readmeBadge: null, tags: null });
|
||
}
|
||
|
||
let pluginVersion = null;
|
||
let homepage = null;
|
||
try {
|
||
const pj = JSON.parse(readFileSync(join(repoDir, '.claude-plugin', 'plugin.json'), 'utf8'));
|
||
pluginVersion = pj.version ?? null;
|
||
homepage = pj.homepage ?? null;
|
||
} catch { /* leave null → flagged */ }
|
||
const homepageReachable = checkHomepage(homepage);
|
||
|
||
let worktreeReadme = null;
|
||
try {
|
||
worktreeReadme = readFileSync(join(repoDir, 'README.md'), 'utf8');
|
||
} catch { /* no README → badge + stat checks skipped */ }
|
||
|
||
// Version-badge check 2 is about the working tree's INTERNAL consistency, so it keeps
|
||
// reading the working tree. The stat mirror is about what the catalog promises users,
|
||
// so it reads the pinned ref instead — see pickStatSource.
|
||
const readmeBadge = worktreeReadme === null ? null : extractBadgeVersion(worktreeReadme);
|
||
const statSource = pickStatSource({ atRef: gitShow(repoDir, catalogRef, 'README.md'), atWorktree: worktreeReadme });
|
||
const statBadges = statSource === null ? null : extractStatBadges(statSource);
|
||
|
||
let catalogLabel = null;
|
||
let catalogStats = null;
|
||
try {
|
||
const catalogReadme = readFileSync(join(catalogDir, 'README.md'), 'utf8');
|
||
catalogLabel = extractCatalogLabel(catalogReadme, name);
|
||
catalogStats = extractCatalogStats(catalogReadme, name);
|
||
} catch { /* no catalog README → label + stat checks skipped */ }
|
||
|
||
return classifyPlugin({ name, catalogRef, pluginVersion, readmeBadge, tags: gitTags(repoDir), catalogLabel, statBadges, catalogStats, homepage, homepageReachable });
|
||
}
|
||
|
||
// Pure — takes classified results, not I/O. Split out of runGate so both directions of
|
||
// the SKIP-vs-green fix are unit-testable without needing a real sibling-repo layout on
|
||
// disk: a run that verified NOTHING
|
||
// (all SKIP) must fail even with zero ERROR/WARN, and a fully-verified clean run must
|
||
// still pass — a fix that only reddens the former without proving the latter would be
|
||
// unverified in the other direction.
|
||
export function gateOutcome(results, { strict = false, allowSkip = false } = {}) {
|
||
const total = results.length;
|
||
const skipped = results.filter(r => r.status === 'SKIP').length;
|
||
const hasError = results.some(r => r.status === 'ERROR');
|
||
const hasWarn = results.some(r => r.status === 'WARN');
|
||
const unverified = skipped > 0 && !allowSkip;
|
||
return {
|
||
total,
|
||
skipped,
|
||
verified: total - skipped,
|
||
hasError,
|
||
hasWarn,
|
||
unverified,
|
||
failed: hasError || (strict && hasWarn) || unverified,
|
||
};
|
||
}
|
||
|
||
export function runGate(catalogDir, { strict = false, allowSkip = false } = {}) {
|
||
const mkt = JSON.parse(readFileSync(join(catalogDir, '.claude-plugin', 'marketplace.json'), 'utf8'));
|
||
const results = (mkt.plugins || []).map(p => inspectPlugin(catalogDir, p));
|
||
return { results, ...gateOutcome(results, { strict, allowSkip }) };
|
||
}
|
||
|
||
const __filename = fileURLToPath(import.meta.url);
|
||
if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
|
||
const strict = process.argv.includes('--strict');
|
||
const allowSkip = process.argv.includes('--allow-skip');
|
||
const catalogDir = join(dirname(__filename), '..');
|
||
const { results, failed, skipped, total, verified } = runGate(catalogDir, { strict, allowSkip });
|
||
|
||
const icon = { OK: '✓', WARN: '⚠', ERROR: '✗', SKIP: '–' };
|
||
for (const r of results) {
|
||
console.log(`${icon[r.status] || '?'} ${r.status.padEnd(5)} ${r.name}`);
|
||
for (const f of r.findings) if (f.level !== 'OK') console.log(` ${f.msg}`);
|
||
}
|
||
const count = (s) => results.filter(r => r.status === s).length;
|
||
console.log('');
|
||
console.log(`${results.length} plugins — ${count('OK')} OK, ${count('WARN')} WARN, ${count('ERROR')} ERROR, ${count('SKIP')} SKIP${strict ? ' (strict)' : ''} — verified ${verified}/${total}`);
|
||
if (skipped > 0 && !allowSkip) {
|
||
console.log(`✗ ${skipped} plugin(s) UNVERIFIED (sibling repo not found locally) — this is NOT a green run.`);
|
||
console.log(` Clone the missing sibling repos next to this one, or pass --allow-skip to accept partial verification explicitly.`);
|
||
}
|
||
process.exit(failed ? 1 : 0);
|
||
}
|