Found live minutes after shippingfc95cbd: llm-security went ERROR on scanners (23 vs 22) and tests (2013 vs 2034). The catalog was RIGHT and the gate was wrong. llm-security had committed past its v7.8.3 tag without bumping the version, and the gate was reading the sibling working tree — but the catalog documents what INSTALLS, and `ref: v7.8.3` still installs 23/2013. Stat badges are now read with `git show <ref>:README.md`, falling back to the working tree only when the ref cannot be read (a ref resolving to nothing is already its own ERROR, so the fallback cannot hide a dangling ref). The version-badge check is unchanged and still reads the working tree: that one is about the plugin's internal consistency, not about what the catalog promises. This also corrects a stat I got wrong infc95cbd. I had moved config-audit from 1410 to 1441 tests off the working tree; at the pinned v5.13.0 the badge says 1398. 1441 is unreleased. The catalog now says 1398 — what installs. [skip-docs]: CLAUDE.md carries the rule and the "check `git show <ref>:README.md` before believing the working tree" instruction; README.md changes by one number because the gate was wrong about it. Tests 117 -> 120 (+3, all regression). Gate green at 11 OK / 0 WARN / 0 ERROR. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDSiMcgLMpEETwtkc86Nym
273 lines
13 KiB
JavaScript
273 lines
13 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)
|
||
// - 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/<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) — 14 axes
|
||
// across 8 of the 11 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 14 numbers stay ungated and need a human
|
||
// pass (repo-mailbox's two were 6 and 251 against a true 8 and 374 when 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.
|
||
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);
|
||
}
|