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
130 lines
5.1 KiB
JavaScript
130 lines
5.1 KiB
JavaScript
// Tests for the marketplace version-consistency gate.
|
|
// Pure classifier is the unit under test — I/O shell (runGate/inspectPlugin) is exercised
|
|
// against the live tree by the CLI, not here.
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
normalizeVersion,
|
|
extractBadgeVersion,
|
|
extractCatalogLabel,
|
|
classifyPlugin,
|
|
} from './check-versions.mjs';
|
|
|
|
test('normalizeVersion strips a leading v', () => {
|
|
assert.equal(normalizeVersion('v5.4.0'), '5.4.0');
|
|
assert.equal(normalizeVersion('5.4.0'), '5.4.0');
|
|
assert.equal(normalizeVersion(' v1.16.0 '), '1.16.0');
|
|
});
|
|
|
|
test('extractBadgeVersion pulls the version from a shields.io badge', () => {
|
|
assert.equal(extractBadgeVersion(''), '5.4.0');
|
|
assert.equal(extractBadgeVersion('no badge here'), null);
|
|
});
|
|
|
|
test('all-consistent plugin → OK', () => {
|
|
const r = classifyPlugin({
|
|
name: 'config-audit', catalogRef: 'v5.4.0', pluginVersion: '5.4.0',
|
|
readmeBadge: '5.4.0', tags: ['v5.4.0', 'v5.3.0'],
|
|
});
|
|
assert.equal(r.status, 'OK');
|
|
assert.ok(!r.findings.some(f => f.level === 'ERROR' || f.level === 'WARN'));
|
|
});
|
|
|
|
test('catalog ref with no matching tag → ERROR (install-breaking)', () => {
|
|
const r = classifyPlugin({
|
|
name: 'voyage', catalogRef: 'v5.5.0', pluginVersion: '5.5.0',
|
|
readmeBadge: '5.5.0', tags: ['v5.1.1'],
|
|
});
|
|
assert.equal(r.status, 'ERROR');
|
|
assert.ok(r.findings.some(f => f.level === 'ERROR' && /no matching git tag/.test(f.msg)));
|
|
});
|
|
|
|
test('plugin.json version != README badge → ERROR (internal corruption)', () => {
|
|
const r = classifyPlugin({
|
|
name: 'x', catalogRef: 'v1.0.0', pluginVersion: '1.0.0',
|
|
readmeBadge: '0.9.0', tags: ['v1.0.0'],
|
|
});
|
|
assert.equal(r.status, 'ERROR');
|
|
assert.ok(r.findings.some(f => f.level === 'ERROR' && /README version-badge/.test(f.msg)));
|
|
});
|
|
|
|
test('catalog ref behind a RELEASED version (tag exists) → WARN, suggests bumping catalog', () => {
|
|
const r = classifyPlugin({
|
|
name: 'x', catalogRef: 'v1.15.0', pluginVersion: '1.16.0',
|
|
readmeBadge: '1.16.0', tags: ['v1.16.0', 'v1.15.0'],
|
|
});
|
|
assert.equal(r.status, 'WARN');
|
|
assert.ok(r.findings.some(f => f.level === 'WARN' && /bump catalog ref/.test(f.msg)));
|
|
});
|
|
|
|
test('plugin.json ahead with no tag of its own → WARN, flags unreleased/untagged', () => {
|
|
const r = classifyPlugin({
|
|
name: 'ms-ai-architect', catalogRef: 'v1.15.0', pluginVersion: '1.16.0',
|
|
readmeBadge: '1.16.0', tags: ['v1.15.0'],
|
|
});
|
|
assert.equal(r.status, 'WARN');
|
|
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,
|
|
readmeBadge: null, tags: null,
|
|
});
|
|
assert.equal(r.status, 'SKIP');
|
|
});
|
|
|
|
test('ERROR dominates WARN when both apply', () => {
|
|
const r = classifyPlugin({
|
|
name: 'x', catalogRef: 'v2.0.0', pluginVersion: '2.1.0',
|
|
readmeBadge: '2.1.0', tags: ['v1.9.0'], // ref v2.0.0 dangling AND catalog behind v2.1.0
|
|
});
|
|
assert.equal(r.status, 'ERROR');
|
|
});
|