ktg-plugin-marketplace/scripts/check-versions.mjs
Kjell Tore Guttormsen a8d44e6d3f 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
2026-06-19 23:19:22 +02:00

122 lines
5.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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);
}