ktg-plugin-marketplace/scripts/release-plugin.test.mjs
Kjell Tore Guttormsen 2e1d206ab3 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
2026-06-22 13:38:23 +02:00

133 lines
6 KiB
JavaScript

// Tests for the atomic plugin-release helper.
// Pure planner is the unit under test — the I/O shell (read files, git tag/commit/push)
// is exercised by the CLI against the live tree, not here.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { planRelease, reconcileReadmeLabel } from './release-plugin.mjs';
import { classifyPlugin } from './check-versions.mjs';
const marketplace = () => ({
name: 'ktg-plugin-marketplace',
plugins: [
{ name: 'alpha', source: { source: 'url', url: 'https://x/alpha.git', ref: 'v1.0.0' }, description: 'a' },
{ name: 'beta', source: { source: 'url', url: 'https://x/beta.git', ref: 'v2.3.0' }, description: 'b' },
],
});
const observed = (o = {}) => ({
pluginVersion: '1.1.0',
readmeBadge: '1.1.0',
tags: ['v1.1.0', 'v1.0.0'],
...o,
});
test('READY: consistent plugin, tag exists, catalog ref behind → bump planned', () => {
const p = planRelease({ marketplace: marketplace(), name: 'alpha', observed: observed() });
assert.equal(p.verdict, 'READY');
assert.equal(p.targetVersion, '1.1.0');
assert.equal(p.currentRef, 'v1.0.0');
assert.equal(p.newRef, 'v1.1.0');
assert.deepEqual(p.blockers, []);
assert.equal(p.commitSubject, 'chore(catalog): bump alpha v1.0.0 -> v1.1.0');
});
test('READY plan bumps ONLY the target plugin and does not mutate the input', () => {
const mkt = marketplace();
const p = planRelease({ marketplace: mkt, name: 'alpha', observed: observed() });
// input untouched
assert.equal(mkt.plugins[0].source.ref, 'v1.0.0');
// output bumped on alpha only
const out = p.newMarketplace.plugins;
assert.equal(out.find(x => x.name === 'alpha').source.ref, 'v1.1.0');
assert.equal(out.find(x => x.name === 'beta').source.ref, 'v2.3.0');
});
test('READY plan is green by construction (post-bump classifyPlugin is OK)', () => {
const o = observed();
const p = planRelease({ marketplace: marketplace(), name: 'alpha', observed: o });
const post = classifyPlugin({
name: 'alpha', catalogRef: p.newRef,
pluginVersion: o.pluginVersion, readmeBadge: o.readmeBadge, tags: o.tags,
});
assert.equal(post.status, 'OK');
});
test('explicit --version targets that version when consistent', () => {
const o = observed({ pluginVersion: '1.1.0', readmeBadge: '1.1.0', tags: ['v1.1.0', 'v1.0.0'] });
const p = planRelease({ marketplace: marketplace(), name: 'alpha', observed: o, targetVersion: '1.1.0' });
assert.equal(p.verdict, 'READY');
assert.equal(p.newRef, 'v1.1.0');
});
test('NOOP: catalog ref already pins the target version', () => {
const o = observed({ pluginVersion: '1.0.0', readmeBadge: '1.0.0', tags: ['v1.0.0'] });
const p = planRelease({ marketplace: marketplace(), name: 'alpha', observed: o });
assert.equal(p.verdict, 'NOOP');
assert.equal(p.newRef, 'v1.0.0');
assert.equal(p.newMarketplace, null);
});
test('BLOCKED: target version has no git tag in the plugin repo', () => {
const o = observed({ pluginVersion: '1.1.0', readmeBadge: '1.1.0', tags: ['v1.0.0'] }); // no v1.1.0
const p = planRelease({ marketplace: marketplace(), name: 'alpha', observed: o });
assert.equal(p.verdict, 'BLOCKED');
assert.ok(p.blockers.some(b => /tag v1\.1\.0/.test(b) && /not found|tag the plugin/.test(b)));
assert.equal(p.newMarketplace, null);
});
test('BLOCKED: plugin.json version != target (asked to release an undeclared version)', () => {
const o = observed({ pluginVersion: '1.1.0', readmeBadge: '1.1.0', tags: ['v1.2.0', 'v1.1.0'] });
const p = planRelease({ marketplace: marketplace(), name: 'alpha', observed: o, targetVersion: '1.2.0' });
assert.equal(p.verdict, 'BLOCKED');
assert.ok(p.blockers.some(b => /plugin\.json/.test(b) && /1\.1\.0/.test(b)));
});
test('BLOCKED: README badge disagrees with plugin.json (internal corruption)', () => {
const o = observed({ pluginVersion: '1.1.0', readmeBadge: '1.0.0', tags: ['v1.1.0'] });
const p = planRelease({ marketplace: marketplace(), name: 'alpha', observed: o });
assert.equal(p.verdict, 'BLOCKED');
assert.ok(p.blockers.some(b => /badge/i.test(b)));
});
test('BLOCKED: plugin not present in the catalog', () => {
const p = planRelease({ marketplace: marketplace(), name: 'ghost', observed: observed() });
assert.equal(p.verdict, 'BLOCKED');
assert.ok(p.blockers.some(b => /not in (the )?catalog/i.test(b)));
assert.equal(p.currentRef, null);
});
test('BLOCKED: target version cannot be resolved (no --version, no plugin.json)', () => {
const o = observed({ pluginVersion: null });
const p = planRelease({ marketplace: marketplace(), name: 'alpha', observed: o });
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);
});