feat(scripts): add marketplace version-consistency gate
scripts/check-versions.mjs cross-checks every plugin in marketplace.json against its sibling repo: - catalog ref must resolve to a real git tag → ERROR (dangling = install breaks) - plugin.json version == README version-badge → ERROR (internal corruption) - catalog ref == plugin.json version → WARN (catalog lags / unreleased bump) - sibling repo missing → SKIP Exit 1 on any ERROR; --strict also fails on WARN. Pure classifier covered by check-versions.test.mjs (9 tests, node --test). Documented in CLAUDE.md catalog-maintenance. Run before committing any ref change. Current run: 8 OK, 2 WARN (linkedin-studio, ms-ai-architect — catalog ref lags an untagged plugin.json bump), 0 ERROR. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ter3E2JSi1Khgmuf2kady8
This commit is contained in:
parent
563dde7c51
commit
a8d44e6d3f
3 changed files with 210 additions and 0 deletions
|
|
@ -18,3 +18,9 @@ their own Forgejo repositories under `https://git.fromaitochitta.com/open/`.
|
|||
- Adding/updating a plugin entry: edit `.claude-plugin/marketplace.json` (external `source: "url"` with
|
||||
a pinned `ref`) and re-state the plugin in README.md with its verified version.
|
||||
- Plugin source, issues, and releases live in each plugin's own repository — not here.
|
||||
- **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`).
|
||||
|
|
|
|||
122
scripts/check-versions.mjs
Normal file
122
scripts/check-versions.mjs
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
#!/usr/bin/env node
|
||||
// Marketplace version-consistency gate.
|
||||
//
|
||||
// For every plugin in catalog/.claude-plugin/marketplace.json, compare its catalog `ref`
|
||||
// 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 ref should equal plugin.json version → else WARN (catalog lags / unreleased bump)
|
||||
// - sibling repo missing → SKIP
|
||||
//
|
||||
// Exit 1 if any ERROR (or any WARN under --strict); 0 otherwise.
|
||||
// Usage: node scripts/check-versions.mjs [--strict]
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
export function normalizeVersion(v) {
|
||||
return String(v).trim().replace(/^v/, '');
|
||||
}
|
||||
|
||||
export function extractBadgeVersion(readmeText) {
|
||||
const m = /badge\/version-(\d+\.\d+\.\d+)/.exec(readmeText || '');
|
||||
return m ? m[1] : 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 }) {
|
||||
if (pluginVersion === null && tags === null) {
|
||||
return { name, status: 'SKIP', findings: [{ level: 'SKIP', msg: 'plugin repo not found locally — cannot verify' }] };
|
||||
}
|
||||
|
||||
const findings = [];
|
||||
|
||||
if (pluginVersion === null) {
|
||||
findings.push({ level: 'ERROR', msg: 'plugin.json version not found or unparseable' });
|
||||
}
|
||||
|
||||
// 1. dangling ref — catalog points at a tag that does not exist in the plugin repo
|
||||
if (tags !== null && !tags.includes(catalogRef)) {
|
||||
findings.push({ level: 'ERROR', msg: `catalog ref ${catalogRef} has no matching git tag in the plugin repo (install-breaking)` });
|
||||
}
|
||||
|
||||
// 2. internal consistency — plugin.json version vs README version-badge
|
||||
if (pluginVersion !== null && readmeBadge !== null && pluginVersion !== readmeBadge) {
|
||||
findings.push({ level: 'ERROR', msg: `plugin.json version ${pluginVersion} != README version-badge ${readmeBadge}` });
|
||||
}
|
||||
|
||||
// 3. catalog ref vs plugin.json version
|
||||
if (pluginVersion !== null && normalizeVersion(catalogRef) !== pluginVersion) {
|
||||
const releasedTag = tags !== null && tags.includes('v' + pluginVersion);
|
||||
const reason = releasedTag
|
||||
? `released version v${pluginVersion} exists as a tag — bump catalog ref`
|
||||
: `no tag for v${pluginVersion} — unreleased bump, or a release that was never tagged`;
|
||||
findings.push({ level: 'WARN', msg: `catalog ref ${catalogRef} != plugin.json version ${pluginVersion} (${reason})` });
|
||||
}
|
||||
|
||||
const status = findings.some(f => f.level === 'ERROR') ? 'ERROR'
|
||||
: findings.some(f => f.level === 'WARN') ? 'WARN'
|
||||
: 'OK';
|
||||
if (findings.length === 0) findings.push({ level: 'OK', msg: `consistent at ${pluginVersion}` });
|
||||
return { name, status, findings };
|
||||
}
|
||||
|
||||
function gitTags(repoDir) {
|
||||
try {
|
||||
const out = execFileSync('git', ['-C', repoDir, 'tag', '--list', 'v*'], { encoding: 'utf8' });
|
||||
return out.split('\n').map(s => s.trim()).filter(Boolean);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// I/O shell — resolve one plugin's observed state from disk, then classify.
|
||||
export function inspectPlugin(catalogDir, plugin) {
|
||||
const name = plugin.name;
|
||||
const catalogRef = plugin.source?.ref ?? null;
|
||||
const repoDir = join(catalogDir, '..', name);
|
||||
|
||||
if (!existsSync(repoDir)) {
|
||||
return classifyPlugin({ name, catalogRef, pluginVersion: null, readmeBadge: null, tags: null });
|
||||
}
|
||||
|
||||
let pluginVersion = null;
|
||||
try {
|
||||
const pj = JSON.parse(readFileSync(join(repoDir, '.claude-plugin', 'plugin.json'), 'utf8'));
|
||||
pluginVersion = pj.version ?? null;
|
||||
} catch { /* leave null → flagged */ }
|
||||
|
||||
let readmeBadge = null;
|
||||
try {
|
||||
readmeBadge = extractBadgeVersion(readFileSync(join(repoDir, 'README.md'), 'utf8'));
|
||||
} catch { /* no README → badge check skipped */ }
|
||||
|
||||
return classifyPlugin({ name, catalogRef, pluginVersion, readmeBadge, tags: gitTags(repoDir) });
|
||||
}
|
||||
|
||||
export function runGate(catalogDir, { strict = false } = {}) {
|
||||
const mkt = JSON.parse(readFileSync(join(catalogDir, '.claude-plugin', 'marketplace.json'), 'utf8'));
|
||||
const results = (mkt.plugins || []).map(p => inspectPlugin(catalogDir, p));
|
||||
const hasError = results.some(r => r.status === 'ERROR');
|
||||
const hasWarn = results.some(r => r.status === 'WARN');
|
||||
return { results, hasError, hasWarn, failed: hasError || (strict && hasWarn) };
|
||||
}
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
|
||||
const strict = process.argv.includes('--strict');
|
||||
const catalogDir = join(dirname(__filename), '..');
|
||||
const { results, failed } = runGate(catalogDir, { strict });
|
||||
|
||||
const icon = { OK: '✓', WARN: '⚠', ERROR: '✗', SKIP: '–' };
|
||||
for (const r of results) {
|
||||
console.log(`${icon[r.status] || '?'} ${r.status.padEnd(5)} ${r.name}`);
|
||||
for (const f of r.findings) if (f.level !== 'OK') console.log(` ${f.msg}`);
|
||||
}
|
||||
const count = (s) => results.filter(r => r.status === s).length;
|
||||
console.log('');
|
||||
console.log(`${results.length} plugins — ${count('OK')} OK, ${count('WARN')} WARN, ${count('ERROR')} ERROR, ${count('SKIP')} SKIP${strict ? ' (strict)' : ''}`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
}
|
||||
82
scripts/check-versions.test.mjs
Normal file
82
scripts/check-versions.test.mjs
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
// 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,
|
||||
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('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');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue