Files
pbakaus_impeccable/scripts/lib/validate-plugin-versions.js
T
Paul BakausandGitHub 482368511a Fix Codex skill version metadata (#703)
Move Codex and .agents skill versions under metadata while keeping all version readers compatible with legacy top-level frontmatter.\n\nAI assistance: prepared with Codex under maintainer direction.
2026-09-01 20:21:01 -04:00

155 lines
5.7 KiB
JavaScript

/**
* Plugin/skill version-drift detection (issue #274).
*
* The Claude Code marketplace installs from the committed `./plugin` subtree,
* so any version disagreement between the hand-edited manifests and the
* generated subtree ships stale content reporting a wrong version. Root
* `.claude-plugin/plugin.json` is the single source of truth (build() reads
* skillsVersion from it). Every other version-bearing file must match it:
*
* - `.claude-plugin/marketplace.json` plugins[0].version — hand-edited
* alongside plugin.json; the post-merge sync workflow can't repair a
* mismatch here because it never bumps versions.
* - `plugin/.claude-plugin/plugin.json` version — generated, derived from
* root at build:release; checked so a bump that forgets to regenerate the
* subtree fails loudly instead of merging a drift window onto main.
* - `plugin/skills/impeccable/SKILL.md` frontmatter version — generated;
* same rationale.
* - `dist/openai/impeccable/.codex-plugin/plugin.json` version — generated
* for public OpenAI submission and checked when that build output exists.
*
* The collector is pure (filesystem-in, data-out) so it can be unit-tested
* against fixtures; build.js owns the logging and the non-zero exit.
*/
import fs from 'fs';
import path from 'path';
/**
* Pull the version value out of a SKILL.md leading frontmatter block.
* CRLF-tolerant (`\r?\n`) to match the shared parseFrontmatter in
* scripts/lib/utils.js — a bundle saved with CRLF line endings must not read
* as a null version and trip a false mismatch. Codex builds carry the version
* under `metadata.version`; legacy provider builds keep the top-level key.
*/
export function readSkillFrontmatterVersion(content) {
const fm = String(content).match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---(?:[ \t]*\r?\n|[ \t]*$)/);
if (!fm) return null;
let metadataVersion = null;
let topLevelVersion = null;
let inMetadata = false;
let metadataIndent = null;
for (const line of fm[1].split(/\r?\n/)) {
if (!line.trim() || line.trimStart().startsWith('#')) continue;
const indentText = line.match(/^[ \t]*/)[0];
const indent = indentText.replace(/\t/g, ' ').length;
if (indent === 0) {
inMetadata = /^metadata:\s*(?:#.*)?$/.test(line);
metadataIndent = null;
const version = line.match(/^version:\s*(.+?)\s*$/);
if (version) topLevelVersion = version[1];
continue;
}
if (!inMetadata) continue;
if (metadataIndent === null) metadataIndent = indent;
if (indent !== metadataIndent) continue;
const version = line.trim().match(/^version:\s*(.+?)\s*$/);
if (version) metadataVersion = version[1];
}
const value = metadataVersion || topLevelVersion;
return value ? value.trim().replace(/^(["'])(.*)\1$/, '$2') : null;
}
/**
* Read a file and extract a value, turning read/parse failures into a clean
* sentinel instead of a raw throw. A version bump is exactly the moment a
* manifest is half-edited, so a malformed file must produce an actionable
* diagnostic naming the file, not a stack trace out of build().
*
* @returns {{ value: any } | { error: string }}
*/
function extractFromFile(absPath, extract) {
let raw;
try {
raw = fs.readFileSync(absPath, 'utf-8');
} catch (err) {
return { error: `could not read file (${err.code || err.message})` };
}
try {
return { value: extract(raw) };
} catch (err) {
return { error: `could not parse (${err.message})` };
}
}
/**
* Compare every version-bearing plugin/skill file against root plugin.json.
*
* @param {string} rootDir repository root
* @returns {{
* source: string|null,
* checked: Array<{relPath:string, found:any}>,
* mismatches: Array<{relPath:string, found:any, expected:string}>,
* errors: Array<{relPath:string, reason:string}>,
* }}
* `source` is null only when root plugin.json is absent (nothing to check).
* A present-but-malformed root, or one missing its `version` field, instead
* reports an entry in `errors` so the build fails loudly rather than passing.
*/
export function collectPluginVersions(rootDir) {
const rootRel = '.claude-plugin/plugin.json';
const rootManifestPath = path.join(rootDir, rootRel);
const empty = { source: null, checked: [], mismatches: [], errors: [] };
if (!fs.existsSync(rootManifestPath)) return empty;
const rootResult = extractFromFile(rootManifestPath, (raw) => JSON.parse(raw).version);
if (rootResult.error) {
return { ...empty, errors: [{ relPath: rootRel, reason: rootResult.error }] };
}
const source = rootResult.value;
if (source == null) {
return { ...empty, errors: [{ relPath: rootRel, reason: 'missing "version" field' }] };
}
const checks = [
{
relPath: '.claude-plugin/marketplace.json',
read: (raw) => JSON.parse(raw).plugins?.[0]?.version,
},
{
relPath: 'plugin/.claude-plugin/plugin.json',
read: (raw) => JSON.parse(raw).version,
},
{
relPath: 'dist/openai/impeccable/.codex-plugin/plugin.json',
read: (raw) => JSON.parse(raw).version,
},
{
relPath: 'plugin/skills/impeccable/SKILL.md',
read: (raw) => readSkillFrontmatterVersion(raw),
},
];
const checked = [];
const mismatches = [];
const errors = [];
for (const { relPath, read } of checks) {
const absPath = path.join(rootDir, relPath);
if (!fs.existsSync(absPath)) continue;
const result = extractFromFile(absPath, read);
if (result.error) {
errors.push({ relPath, reason: result.error });
continue;
}
const found = result.value;
checked.push({ relPath, found });
if (found !== source) mismatches.push({ relPath, found, expected: source });
}
return { source, checked, mismatches, errors };
}