Files
pbakaus_impeccable/scripts/lib/validate-plugin-versions.js
T
Paul BakausandGitHub da99645a58 Add OpenAI plugin submission bundle (#363)
* Add OpenAI plugin submission bundle

Build a Codex-native OpenAI plugin with bundled hooks, public listing metadata, submission guidance, privacy coverage, and regression tests.

AI assistance: OpenAI Codex prepared and validated these changes under maintainer direction.

* Fix provider script command rendering

Replace heuristic rewrites across executable scripts with one explicit provider marker, render pinned shortcuts per target harness, and remove the personal email from the public publisher manifest.

Addresses automated review feedback on PR #363.

AI assistance: OpenAI Codex prepared and validated these changes under maintainer direction.
2026-07-09 17:09:13 -07:00

129 lines
4.8 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. `(.+)` stops at the line
* terminator (so a trailing `\r` is excluded), and `.trim()` mops up the rest.
*/
export function readSkillFrontmatterVersion(content) {
const fm = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!fm) return null;
const line = fm[1].match(/^version:\s*(.+)/m);
return line ? line[1].trim().replace(/^['"]|['"]$/g, '') : 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 };
}