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

@ -24,12 +24,16 @@ their own Forgejo repositories under `https://git.fromaitochitta.com/open/`.
`node scripts/release-plugin.mjs <plugin> [--version X.Y.Z]` — dry-run by default; it REFUSES unless `node scripts/release-plugin.mjs <plugin> [--version X.Y.Z]` — dry-run by default; it REFUSES unless
`plugin.json` == README badge == the target version AND the `vX.Y.Z` tag exists, then prints the `plugin.json` == README badge == the target version AND the `vX.Y.Z` tag exists, then prints the
planned bump. Apply with `--write [--commit] [--push]`; `--create-tag` mints+pushes a missing plugin planned bump. Apply with `--write [--commit] [--push]`; `--create-tag` mints+pushes a missing plugin
tag first. Because it only moves the `ref` to a verified, tagged, consistent version, tag first. On `--write` it bumps the catalog `ref` AND the catalog README's per-plugin `` `vX.Y.Z` ``
`check-versions.mjs` is green by construction. Never hand-edit a `ref` for a release — use this. label together (and `git add`s both on `--commit`). Because it only moves both to a verified, tagged,
Pure planner covered by `scripts/release-plugin.test.mjs`. consistent version, `check-versions.mjs` is green by construction. Never hand-edit a `ref` or a
README label for a release — use this. Pure planner + label reconciler covered by
`scripts/release-plugin.test.mjs`.
- **Version-consistency gate:** run `node scripts/check-versions.mjs` before committing any `ref` - **Version-consistency gate:** run `node scripts/check-versions.mjs` before committing any `ref`
change. For each plugin it checks (against the sibling repo) that the catalog `ref` resolves to a change. For each plugin it checks (against the sibling repo) that the catalog `ref` resolves to a
real git tag (ERROR if dangling — breaks install), that `plugin.json` version == README real git tag (ERROR if dangling — breaks install), that `plugin.json` version == README
version-badge (ERROR), and that the catalog `ref` matches `plugin.json` version (WARN — catalog version-badge (ERROR), that the catalog README's per-plugin `` `vX.Y.Z` `` label == the catalog
lags or an unreleased bump). Exit 1 on any ERROR; `--strict` also fails on WARN. Pure-function `ref` (ERROR — the human-facing doc must not misstate the installed version), and that the catalog
core covered by `scripts/check-versions.test.mjs` (`node --test scripts/check-versions.test.mjs`). `ref` matches `plugin.json` version (WARN — catalog lags or an unreleased bump). Exit 1 on any
ERROR; `--strict` also fails on WARN. Pure-function core covered by
`scripts/check-versions.test.mjs` (`node --test scripts/check-versions.test.mjs`).

View file

@ -35,7 +35,7 @@ Key commands: `/security posture`, `/security audit`, `/security scan`, `/securi
--- ---
### [Config-Audit](https://git.fromaitochitta.com/open/config-audit) `v5.5.0` ### [Config-Audit](https://git.fromaitochitta.com/open/config-audit) `v5.7.0`
Configuration intelligence for Claude Code. Claude reads instructions from 7+ file types across multiple scopes; this plugin tells you what's wrong, what's missing, what's silently conflicting, what's actually loaded, and where you're burning tokens. Configuration intelligence for Claude Code. Claude reads instructions from 7+ file types across multiple scopes; this plugin tells you what's wrong, what's missing, what's silently conflicting, what's actually loaded, and where you're burning tokens.
@ -51,7 +51,7 @@ Key commands: `/config-audit posture`, `/config-audit feature-gap`, `/config-aud
--- ---
### [Voyage](https://git.fromaitochitta.com/open/voyage) `v5.1.1` ### [Voyage](https://git.fromaitochitta.com/open/voyage) `v5.6.0`
A six-command planning pipeline with specialized agent swarms, adversarial review, and zero-friction multi-session resumption. Renamed from `ultraplan-local`/`/ultra*-local` to avoid collision with Anthropic's `/ultraplan` and `/ultrareview`. No cloud dependency. A six-command planning pipeline with specialized agent swarms, adversarial review, and zero-friction multi-session resumption. Renamed from `ultraplan-local`/`/ultra*-local` to avoid collision with Anthropic's `/ultraplan` and `/ultrareview`. No cloud dependency.

View file

@ -5,6 +5,7 @@
// against the plugin repo found as a sibling directory (../<name>): // against the plugin repo found as a sibling directory (../<name>):
// - catalog ref must point at an existing git tag → else ERROR (install-breaking) // - 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) // - 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) // - catalog ref should equal plugin.json version → else WARN (catalog lags / unreleased bump)
// - sibling repo missing → SKIP // - sibling repo missing → SKIP
// //
@ -24,9 +25,23 @@ export function extractBadgeVersion(readmeText) {
return m ? m[1] : null; 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. // 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". // 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) { if (pluginVersion === null && tags === null) {
return { name, status: 'SKIP', findings: [{ level: 'SKIP', msg: 'plugin repo not found locally — cannot verify' }] }; 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}` }); 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) { if (pluginVersion !== null && normalizeVersion(catalogRef) !== pluginVersion) {
const releasedTag = tags !== null && tags.includes('v' + pluginVersion); const releasedTag = tags !== null && tags.includes('v' + pluginVersion);
const reason = releasedTag const reason = releasedTag
@ -93,7 +114,12 @@ export function inspectPlugin(catalogDir, plugin) {
readmeBadge = extractBadgeVersion(readFileSync(join(repoDir, 'README.md'), 'utf8')); readmeBadge = extractBadgeVersion(readFileSync(join(repoDir, 'README.md'), 'utf8'));
} catch { /* no README → badge check skipped */ } } 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 } = {}) { export function runGate(catalogDir, { strict = false } = {}) {

View file

@ -6,6 +6,7 @@ import assert from 'node:assert/strict';
import { import {
normalizeVersion, normalizeVersion,
extractBadgeVersion, extractBadgeVersion,
extractCatalogLabel,
classifyPlugin, classifyPlugin,
} from './check-versions.mjs'; } 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))); 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', () => { test('plugin repo not found locally → SKIP', () => {
const r = classifyPlugin({ const r = classifyPlugin({
name: 'gone', catalogRef: 'v1.0.0', pluginVersion: null, 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 // 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, // 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 // then bumps the ref AND the catalog README's per-plugin label together so
// planner (planRelease) is fully tested; the I/O shell reads the tree and, under // `check-versions.mjs` is green by construction (it now also gates label == ref).
// explicit flags, writes/commits/pushes. Dry-run by default — it changes nothing // The pure planner (planRelease) and label reconciler (reconcileReadmeLabel) are
// until you pass --write. // 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: // Usage:
// node scripts/release-plugin.mjs <name> [--version X.Y.Z] # dry-run: print the plan // 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 -------------------------------------------------------------- // --- I/O shell --------------------------------------------------------------
function gitTags(repoDir) { function gitTags(repoDir) {
@ -157,7 +175,7 @@ function main() {
// READY // READY
if (!args.write) { 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.'); console.log(' (dry-run) re-run with --write [--commit] [--push] to apply.');
process.exit(0); process.exit(0);
} }
@ -165,6 +183,18 @@ function main() {
writeFileSync(mktPath, JSON.stringify(plan.newMarketplace, null, 2) + '\n', 'utf8'); writeFileSync(mktPath, JSON.stringify(plan.newMarketplace, null, 2) + '\n', 'utf8');
console.log(` ✓ wrote ${mktPath} (ref ${plan.currentRef} -> ${plan.newRef})`); 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. // 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 gate = execFileSync('node', [join(catalogDir, 'scripts', 'check-versions.mjs')], { cwd: catalogDir, encoding: 'utf8' });
const line = gate.split('\n').find(l => l.includes(args.name)) ?? ''; const line = gate.split('\n').find(l => l.includes(args.name)) ?? '';
@ -173,7 +203,7 @@ function main() {
if (args.commit) { 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 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`; 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' }); execFileSync('git', ['-C', catalogDir, 'commit', '-m', msg], { stdio: 'inherit' });
console.log(' ✓ committed the catalog'); console.log(' ✓ committed the catalog');
if (args.push) { if (args.push) {

View file

@ -3,7 +3,7 @@
// is exercised by the CLI against the live tree, not here. // is exercised by the CLI against the live tree, not here.
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; 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'; import { classifyPlugin } from './check-versions.mjs';
const marketplace = () => ({ const marketplace = () => ({
@ -102,3 +102,32 @@ test('BLOCKED: target version cannot be resolved (no --version, no plugin.json)'
assert.equal(p.verdict, 'BLOCKED'); assert.equal(p.verdict, 'BLOCKED');
assert.ok(p.blockers.some(b => /resolve.*version|version.*not/i.test(b))); 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);
});