feat(catalog): gate catalog README labels against ref + close the drift

The catalog README's per-plugin `vX.Y.Z` labels were an UNGUARDED surface:
check-versions validated each plugin's OWN README badge, never the catalog
README's labels, so they drifted (config-audit shown v5.5.0 while pinned to
v5.7.0; voyage v5.1.1 while pinned to v5.6.0) — the doc misstated what
`claude plugin install` actually resolves.

Close the class, same pattern as the ref surface:
- check-versions.mjs: new ERROR rule "catalog README label == catalog ref"
  via pure extractCatalogLabel() (matches the /open/<name>) heading, takes the
  first `vX.Y.Z`, ignores a trailing lang/flag badge). No legitimate transient
  state lets label != ref, so ERROR (not WARN). +5 tests.
- release-plugin.mjs: on --write, bump the README label atomically with the ref
  via pure reconcileReadmeLabel() and git add README.md on --commit, so future
  releases keep label and ref in lock-step. +4 tests.
- README.md: reconcile the 2 stale labels (config-audit -> v5.7.0,
  voyage -> v5.6.0). Gate now 9 OK / 1 WARN / 0 ERROR.
- CLAUDE.md: doctrine updated to document the new gate rule + atomic label bump.

ms-ai-architect stays WARN by decision (1.16.0 is unreleased WIP, never tagged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cm2RxKbomdLqjiWGcwCCPi
This commit is contained in:
Kjell Tore Guttormsen 2026-06-22 13:38:23 +02:00
commit 2e1d206ab3
6 changed files with 155 additions and 18 deletions

View file

@ -5,6 +5,7 @@
// 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
//
@ -24,9 +25,23 @@ export function extractBadgeVersion(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;
}
// 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 }) {
export function classifyPlugin({ name, catalogRef, pluginVersion, readmeBadge, tags, catalogLabel = null }) {
if (pluginVersion === null && tags === null) {
return { name, status: 'SKIP', findings: [{ level: 'SKIP', msg: 'plugin repo not found locally — cannot verify' }] };
}
@ -47,7 +62,13 @@ export function classifyPlugin({ name, catalogRef, pluginVersion, readmeBadge, t
findings.push({ level: 'ERROR', msg: `plugin.json version ${pluginVersion} != README version-badge ${readmeBadge}` });
}
// 3. catalog ref vs plugin.json version
// 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
@ -93,7 +114,12 @@ export function inspectPlugin(catalogDir, plugin) {
readmeBadge = extractBadgeVersion(readFileSync(join(repoDir, 'README.md'), 'utf8'));
} catch { /* no README → badge check skipped */ }
return classifyPlugin({ name, catalogRef, pluginVersion, readmeBadge, tags: gitTags(repoDir) });
let catalogLabel = null;
try {
catalogLabel = extractCatalogLabel(readFileSync(join(catalogDir, 'README.md'), 'utf8'), name);
} catch { /* no catalog README → label check skipped */ }
return classifyPlugin({ name, catalogRef, pluginVersion, readmeBadge, tags: gitTags(repoDir), catalogLabel });
}
export function runGate(catalogDir, { strict = false } = {}) {

View file

@ -6,6 +6,7 @@ import assert from 'node:assert/strict';
import {
normalizeVersion,
extractBadgeVersion,
extractCatalogLabel,
classifyPlugin,
} from './check-versions.mjs';
@ -65,6 +66,53 @@ test('plugin.json ahead with no tag of its own → WARN, flags unreleased/untagg
assert.ok(r.findings.some(f => f.level === 'WARN' && /never tagged|unreleased/.test(f.msg)));
});
test('extractCatalogLabel reads the catalog README label by plugin name', () => {
const readme = [
'### [Config-Audit](https://git.fromaitochitta.com/open/config-audit) `v5.7.0`',
'### [MS AI Architect](https://git.fromaitochitta.com/open/ms-ai-architect) `v1.15.0` `🇳🇴 Norwegian`',
].join('\n');
assert.equal(extractCatalogLabel(readme, 'config-audit'), '5.7.0');
// first vX.Y.Z token wins — trailing flag/lang badge on the same line is ignored
assert.equal(extractCatalogLabel(readme, 'ms-ai-architect'), '1.15.0');
// no heading for this plugin → null (check is skipped)
assert.equal(extractCatalogLabel(readme, 'ghost'), null);
});
test('catalog README label != catalog ref → ERROR (doc misstates installed version)', () => {
const r = classifyPlugin({
name: 'config-audit', catalogRef: 'v5.7.0', pluginVersion: '5.7.0',
readmeBadge: '5.7.0', tags: ['v5.7.0'], catalogLabel: '5.5.0',
});
assert.equal(r.status, 'ERROR');
assert.ok(r.findings.some(f => f.level === 'ERROR' && /README label/.test(f.msg)));
});
test('catalog README label == catalog ref → no label finding (stays OK)', () => {
const r = classifyPlugin({
name: 'config-audit', catalogRef: 'v5.7.0', pluginVersion: '5.7.0',
readmeBadge: '5.7.0', tags: ['v5.7.0'], catalogLabel: '5.7.0',
});
assert.equal(r.status, 'OK');
});
test('label check does not fire on the legitimate ref-lags-plugin.json WARN case', () => {
// ref v1.15.0 lags plugin.json 1.16.0 (WARN), but the label matches the ref → no extra ERROR
const r = classifyPlugin({
name: 'ms-ai-architect', catalogRef: 'v1.15.0', pluginVersion: '1.16.0',
readmeBadge: '1.16.0', tags: ['v1.15.0'], catalogLabel: '1.15.0',
});
assert.equal(r.status, 'WARN');
assert.ok(!r.findings.some(f => f.level === 'ERROR'));
});
test('catalogLabel omitted (legacy callers) → label check skipped', () => {
const r = classifyPlugin({
name: 'x', catalogRef: 'v1.0.0', pluginVersion: '1.0.0',
readmeBadge: '1.0.0', tags: ['v1.0.0'],
});
assert.equal(r.status, 'OK');
});
test('plugin repo not found locally → SKIP', () => {
const r = classifyPlugin({
name: 'gone', catalogRef: 'v1.0.0', pluginVersion: null,

View file

@ -10,10 +10,11 @@
//
// This helper makes the catalog side impossible to do wrong: it REFUSES unless
// plugin.json == README badge == the target version AND the vX.Y.Z tag exists,
// then bumps the ref so `check-versions.mjs` is green by construction. The pure
// planner (planRelease) is fully tested; the I/O shell reads the tree and, under
// explicit flags, writes/commits/pushes. Dry-run by default — it changes nothing
// until you pass --write.
// then bumps the ref AND the catalog README's per-plugin label together so
// `check-versions.mjs` is green by construction (it now also gates label == ref).
// The pure planner (planRelease) and label reconciler (reconcileReadmeLabel) are
// fully tested; the I/O shell reads the tree and, under explicit flags,
// writes/commits/pushes. Dry-run by default — it changes nothing until you pass --write.
//
// Usage:
// node scripts/release-plugin.mjs <name> [--version X.Y.Z] # dry-run: print the plan
@ -87,6 +88,23 @@ export function planRelease({ marketplace, name, observed, targetVersion }) {
};
}
// Bump the catalog README's per-plugin label so the human-facing doc matches the new ref.
// Replaces the FIRST `vX.Y.Z` token on the plugin's `/open/<name>)` heading line, leaving any
// trailing lang/flag badge untouched. Returns the new text, or null if nothing changed
// (label already correct, or the plugin has no heading).
export function reconcileReadmeLabel(readmeText, name, newRef) {
let changed = false;
const out = String(readmeText || '').split('\n').map(line => {
if (!changed && line.includes(`/open/${name})`)) {
const replaced = line.replace(/`v\d+\.\d+\.\d+`/, '`' + newRef + '`');
if (replaced !== line) changed = true;
return replaced;
}
return line;
});
return changed ? out.join('\n') : null;
}
// --- I/O shell --------------------------------------------------------------
function gitTags(repoDir) {
@ -157,7 +175,7 @@ function main() {
// READY
if (!args.write) {
console.log(` ✓ ready — would bump catalog ref and commit:\n ${plan.commitSubject}`);
console.log(` ✓ ready — would bump catalog ref + README label and commit:\n ${plan.commitSubject}`);
console.log(' (dry-run) re-run with --write [--commit] [--push] to apply.');
process.exit(0);
}
@ -165,6 +183,18 @@ function main() {
writeFileSync(mktPath, JSON.stringify(plan.newMarketplace, null, 2) + '\n', 'utf8');
console.log(` ✓ wrote ${mktPath} (ref ${plan.currentRef} -> ${plan.newRef})`);
// Keep the human-facing catalog README label in lock-step with the ref (gated by check-versions).
const readmePath = join(catalogDir, 'README.md');
try {
const newReadme = reconcileReadmeLabel(readFileSync(readmePath, 'utf8'), plan.name, plan.newRef);
if (newReadme !== null) {
writeFileSync(readmePath, newReadme, 'utf8');
console.log(` ✓ updated README label (${plan.name} -> ${plan.newRef})`);
} else {
console.log(` · README label already ${plan.newRef} (or no heading found)`);
}
} catch { console.log(' · no catalog README to update'); }
// Confirm the gate is green for this plugin after the write.
const gate = execFileSync('node', [join(catalogDir, 'scripts', 'check-versions.mjs')], { cwd: catalogDir, encoding: 'utf8' });
const line = gate.split('\n').find(l => l.includes(args.name)) ?? '';
@ -173,7 +203,7 @@ function main() {
if (args.commit) {
const body = `${plan.name} ${plan.newRef} — release. Catalog ref now pins the ${plan.newRef} tag so \`claude plugin update\` resolves the release.`;
const msg = `${plan.commitSubject}\n\n${body}\n\nCo-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>\n`;
execFileSync('git', ['-C', catalogDir, 'add', '.claude-plugin/marketplace.json'], { stdio: 'inherit' });
execFileSync('git', ['-C', catalogDir, 'add', '.claude-plugin/marketplace.json', 'README.md'], { stdio: 'inherit' });
execFileSync('git', ['-C', catalogDir, 'commit', '-m', msg], { stdio: 'inherit' });
console.log(' ✓ committed the catalog');
if (args.push) {

View file

@ -3,7 +3,7 @@
// is exercised by the CLI against the live tree, not here.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { planRelease } from './release-plugin.mjs';
import { planRelease, reconcileReadmeLabel } from './release-plugin.mjs';
import { classifyPlugin } from './check-versions.mjs';
const marketplace = () => ({
@ -102,3 +102,32 @@ test('BLOCKED: target version cannot be resolved (no --version, no plugin.json)'
assert.equal(p.verdict, 'BLOCKED');
assert.ok(p.blockers.some(b => /resolve.*version|version.*not/i.test(b)));
});
// --- reconcileReadmeLabel: keeps the catalog README label in lock-step with the ref ---
test('reconcileReadmeLabel bumps only the target plugin heading label', () => {
const readme = [
'### [Config-Audit](https://git.fromaitochitta.com/open/config-audit) `v5.5.0`',
'body text',
'### [Voyage](https://git.fromaitochitta.com/open/voyage) `v5.1.1`',
].join('\n');
const out = reconcileReadmeLabel(readme, 'config-audit', 'v5.7.0');
assert.ok(out.includes('/open/config-audit) `v5.7.0`'));
assert.ok(out.includes('/open/voyage) `v5.1.1`')); // untouched
});
test('reconcileReadmeLabel leaves a trailing lang/flag badge intact', () => {
const readme = '### [MS AI Architect](https://x/open/ms-ai-architect) `v1.15.0` `🇳🇴 Norwegian`';
const out = reconcileReadmeLabel(readme, 'ms-ai-architect', 'v1.16.0');
assert.equal(out, '### [MS AI Architect](https://x/open/ms-ai-architect) `v1.16.0` `🇳🇴 Norwegian`');
});
test('reconcileReadmeLabel returns null when the label already matches (no-op)', () => {
const readme = '### [Config-Audit](https://x/open/config-audit) `v5.7.0`';
assert.equal(reconcileReadmeLabel(readme, 'config-audit', 'v5.7.0'), null);
});
test('reconcileReadmeLabel returns null when the plugin has no heading', () => {
const readme = '### [Other](https://x/open/other) `v1.0.0`';
assert.equal(reconcileReadmeLabel(readme, 'ghost', 'v2.0.0'), null);
});