From a8d44e6d3fa672d35fdcc39b365720c062377d00 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Fri, 19 Jun 2026 23:19:22 +0200 Subject: [PATCH] feat(scripts): add marketplace version-consistency gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01Ter3E2JSi1Khgmuf2kady8 --- CLAUDE.md | 6 ++ scripts/check-versions.mjs | 122 ++++++++++++++++++++++++++++++++ scripts/check-versions.test.mjs | 82 +++++++++++++++++++++ 3 files changed, 210 insertions(+) create mode 100644 scripts/check-versions.mjs create mode 100644 scripts/check-versions.test.mjs diff --git a/CLAUDE.md b/CLAUDE.md index 1f6502e..3489efe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`). diff --git a/scripts/check-versions.mjs b/scripts/check-versions.mjs new file mode 100644 index 0000000..c2ad07d --- /dev/null +++ b/scripts/check-versions.mjs @@ -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 (../): +// - 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); +} diff --git a/scripts/check-versions.test.mjs b/scripts/check-versions.test.mjs new file mode 100644 index 0000000..4d0141d --- /dev/null +++ b/scripts/check-versions.test.mjs @@ -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('![Version](https://img.shields.io/badge/version-5.4.0-blue)'), '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'); +});