From 2e1d206ab379a975e093b8ead121261482d11ad4 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Mon, 22 Jun 2026 13:38:23 +0200 Subject: [PATCH] feat(catalog): gate catalog README labels against ref + close the drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/) 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) Claude-Session: https://claude.ai/code/session_01Cm2RxKbomdLqjiWGcwCCPi --- CLAUDE.md | 16 ++++++----- README.md | 4 +-- scripts/check-versions.mjs | 32 +++++++++++++++++++--- scripts/check-versions.test.mjs | 48 +++++++++++++++++++++++++++++++++ scripts/release-plugin.mjs | 42 ++++++++++++++++++++++++----- scripts/release-plugin.test.mjs | 31 ++++++++++++++++++++- 6 files changed, 155 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c37bca2..1137bc9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,12 +24,16 @@ their own Forgejo repositories under `https://git.fromaitochitta.com/open/`. `node scripts/release-plugin.mjs [--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 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, - `check-versions.mjs` is green by construction. Never hand-edit a `ref` for a release — use this. - Pure planner covered by `scripts/release-plugin.test.mjs`. + tag first. On `--write` it bumps the catalog `ref` AND the catalog README's per-plugin `` `vX.Y.Z` `` + label together (and `git add`s both on `--commit`). Because it only moves both to a verified, tagged, + 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` 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 - version-badge (ERROR), and that the catalog `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`). + version-badge (ERROR), that the catalog README's per-plugin `` `vX.Y.Z` `` label == the catalog + `ref` (ERROR — the human-facing doc must not misstate the installed version), and that the catalog + `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`). diff --git a/README.md b/README.md index 9ff47d5..a55d491 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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. diff --git a/scripts/check-versions.mjs b/scripts/check-versions.mjs index c2ad07d..c1e3a1f 100644 --- a/scripts/check-versions.mjs +++ b/scripts/check-versions.mjs @@ -5,6 +5,7 @@ // against the plugin repo found as a sibling directory (../): // - 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/)` 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 } = {}) { diff --git a/scripts/check-versions.test.mjs b/scripts/check-versions.test.mjs index 4d0141d..0a30781 100644 --- a/scripts/check-versions.test.mjs +++ b/scripts/check-versions.test.mjs @@ -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, diff --git a/scripts/release-plugin.mjs b/scripts/release-plugin.mjs index b2ac4a0..7fb06c3 100644 --- a/scripts/release-plugin.mjs +++ b/scripts/release-plugin.mjs @@ -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 [--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/)` 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) \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) { diff --git a/scripts/release-plugin.test.mjs b/scripts/release-plugin.test.mjs index 5aa035f..6d949a8 100644 --- a/scripts/release-plugin.test.mjs +++ b/scripts/release-plugin.test.mjs @@ -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); +});