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:
parent
cf2420f7c5
commit
2e1d206ab3
6 changed files with 155 additions and 18 deletions
16
CLAUDE.md
16
CLAUDE.md
|
|
@ -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
|
||||
`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`).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 } = {}) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue