ktg-plugin-marketplace/scripts/check-versions.mjs
Kjell Tore Guttormsen e6b734c4fb docs(catalog): re-measure badge coverage at pinned refs (27 gated / 15 ungated across 12 plugins)
The 2026-08-02 reading (24 gated / 15 ungated across 7 of 11 plugins) predated
repo-standard entering the catalog and repo-mailbox badging its two remaining
axes. Re-measured 2026-08-04 at every pinned ref with the gate's own parsers:
42 catalog axis-claims, 27 badge-covered, 15 badge-less across 7 of 12 plugins,
source=ref for 12/12. The two readings reconcile exactly (24+3, 15-2+2).

Two premisses corrected while measuring:

- graceful-handoff has NOT lost its tests/hooks badges. Both badge-removing
  commits are unreleased (after v3.1.0), so at the pinned ref the catalog's
  "30 tests" is gated. Recorded at pickStatSource as the opposite failure
  direction to the llm-security case: reading the worktree misreports COVERAGE,
  not just values, and neither example alone would have caught it.

- the "251 selftest checks" correction is not settled: CLAUDE.md said 370,
  check-versions.mjs said 374, and repo-mailbox's own README at v0.19.0 implies
  390. Moot for the gate (badged and green at v0.20.2) and not re-measured, so
  the disagreement is flagged rather than laundered into one number.

Badge-less VALUES were not re-audited this session and do not need to be: only
two refs have moved since their audit, so all 15 stand measured at the ref they
are pinned to. Re-measurement is triggered by a ref moving, not by the calendar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PtnSwCLpUR3Hk8SHkPoxX4
2026-08-04 21:43:52 +02:00

284 lines
14 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) — 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 }) {
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);
}