#!/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) // - sibling repo missing → SKIP // // Exit 1 if any ERROR (or any WARN under --strict); 0 otherwise. // Usage: node scripts/check-versions.mjs [--strict] 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 }) { 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)`, }); } } } 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; } } // 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; try { const pj = JSON.parse(readFileSync(join(repoDir, '.claude-plugin', 'plugin.json'), 'utf8')); pluginVersion = pj.version ?? null; } catch { /* leave null → flagged */ } 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 }); } export function runGate(catalogDir, { strict = false } = {}) { const mkt = JSON.parse(readFileSync(join(catalogDir, '.claude-plugin', 'marketplace.json'), 'utf8')); const results = (mkt.plugins || []).map(p => inspectPlugin(catalogDir, p)); const hasError = results.some(r => r.status === 'ERROR'); const hasWarn = results.some(r => r.status === 'WARN'); return { results, hasError, hasWarn, failed: hasError || (strict && hasWarn) }; } 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 catalogDir = join(dirname(__filename), '..'); const { results, failed } = runGate(catalogDir, { strict }); 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)' : ''}`); process.exit(failed ? 1 : 0); }