#!/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 (../): // - 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/)` 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(/(? " 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); }