ktg-plugin-marketplace/scripts/check-versions.mjs
Kjell Tore Guttormsen fc95cbd300 feat(check-versions): gate the catalog's stat lines against plugin badges [skip-docs]
[skip-docs]: the user-facing doc here is CLAUDE.md, not README.md. This is a
maintainer-side consistency gate — the catalog landing page should not describe
it — and CLAUDE.md gains a full paragraph covering the rule, the measured
badge-less gap, and the never-hand-edit-a-stat-line instruction. README.md
changes by exactly one number because the gate found it wrong.

The catalog restates each plugin's counts in a per-plugin stat line, and those
numbers rot silently: nothing compared them to anything. Measured across all 11
plugin READMEs today, config-audit's line claimed 1410 tests while the plugin's
own badge said 1441.

Rule is PER-AXIS, not per-plugin. For each number on a catalog stat line, if the
plugin carries a shields badge for that axis, they must agree (ERROR otherwise);
if it carries no such badge, the axis is skipped. This was measured, not assumed:
21 axis-pairs are badge-covered, but 14 axes across 8 of the 11 plugins are
badge-less (voyage 4, ms-ai-architect 3, repo-mailbox 2, ai-psychosis 2,
linkedin-studio 1, claude-design 1, graceful-handoff 1). A per-plugin exception
list — the original sketch, scoped around voyage alone — would have had to name 8
of 11 repos and be hand-edited for every new axis.

Stated plainly rather than hidden: those 14 axes stay ungated. repo-mailbox's two
were 6 and 251 against a true 8 and 374 (fixed in e9054f8).

Parsing is measured against the real corpus: shields' `--` literal-hyphen escape,
`_`/`%20` spaces, zero as a real count, non-numeric badges (version/platform/
license) excluded, parenthetical asides read as their own axis ("5 skills (389
docs)"), doc/docs/references/reference_docs/knowledge_docs folded onto one axis,
and `N+` read as a lower bound.

Only the catalog's config-audit number changed; the plugin badge is the source.
Tests 106 -> 117 (+11). 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
2026-08-02 21:12:50 +02:00

245 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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]));
}
}
// 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 };
}
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 readmeBadge = null;
let statBadges = null;
try {
const pluginReadme = readFileSync(join(repoDir, 'README.md'), 'utf8');
readmeBadge = extractBadgeVersion(pluginReadme);
statBadges = extractStatBadges(pluginReadme);
} catch { /* no README → badge + stat checks skipped */ }
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);
}