Files
pbakaus_impeccable/scripts/lib/validate-plugin-versions.js
T
793feda5a0 Guard plugin/skill version drift in the build (issue #274) (#278)
* Guard plugin/skill version drift in the build (issue #274)

The Claude Code marketplace installs from the committed ./plugin subtree,
so a version disagreement between the hand-edited manifests and the
generated subtree ships stale content under a wrong version. This is the
class of bug reported in #274: a version bump that doesn't regenerate
./plugin (e.g. PR #252, where root plugin.json was 3.7.0 while
plugin/.claude-plugin/plugin.json was still 3.6.0) merges a drift window
onto main, and marketplace/Cowork installs pull the stale subtree.

Add a build-time validator that treats root .claude-plugin/plugin.json
as the source of truth and fails the build if any of these disagree:
  - .claude-plugin/marketplace.json plugins[0].version (hand-edited; the
    post-merge sync workflow never bumps versions, so it can't repair a
    mismatch here)
  - plugin/.claude-plugin/plugin.json version (generated subtree)
  - plugin/skills/impeccable/SKILL.md frontmatter version (bundled skill)

It only fires on an inconsistent bump; PRs that don't touch versions keep
every file in agreement and stay silent. The pure comparison lives in
scripts/lib/validate-plugin-versions.js with direct unit coverage; build.js
owns the logging and the non-zero exit. Documents the regenerate-on-bump
step in CLAUDE.md's Versioning section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Harden version-drift collector against malformed/incomplete manifests

Address Greptile review on #278:

- Wrap every file read/parse in a sentinel helper (extractFromFile) so a
  half-edited manifest — the exact state during a version bump — yields a
  clean "could not parse (...)" diagnostic naming the file instead of a raw
  JSON.parse stack trace out of build().
- Report a present-but-malformed root plugin.json, or one missing its
  `version` field, as an explicit error. Previously `undefined` version
  short-circuited the build wrapper's `source == null` guard and passed
  silently. collectPluginVersions now returns an `errors` array; build.js
  fails on errors + mismatches combined, and only the genuinely-absent root
  manifest is a no-op skip.

Adds 4 unit tests: malformed checked manifest, malformed root, missing
version field, and the absent-root no-errors case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Make SKILL.md frontmatter version read CRLF-tolerant

Address Cursor Bugbot review on #278: readSkillFrontmatterVersion only
matched `\n` delimiters, while the shared parseFrontmatter in
scripts/lib/utils.js accepts `\r?\n`. A bundled SKILL.md saved with CRLF
line endings would parse to a null version and trip a false mismatch
against root plugin.json even when the version line is correct.

Match the shared parser's `\r?\n` tolerance and drop the `$` anchor on
the version line (it would not match before a `\r`). Adds CRLF coverage
for both readSkillFrontmatterVersion and collectPluginVersions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Re-trigger CI (no file change)

CI did not fire for 5cda9f6b; force a fresh run on the current tree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 15:51:16 +09:00

123 lines
4.6 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.
*
* 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: '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 };
}