mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
* 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>
178 lines
7.4 KiB
JavaScript
178 lines
7.4 KiB
JavaScript
/**
|
|
* Unit coverage for the plugin/skill version-drift guard (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 number. The
|
|
* guard treats root `.claude-plugin/plugin.json` as the source of truth and
|
|
* flags any other version-bearing file that disagrees.
|
|
*/
|
|
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
import fs from 'fs';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
import {
|
|
collectPluginVersions,
|
|
readSkillFrontmatterVersion,
|
|
} from '../scripts/lib/validate-plugin-versions.js';
|
|
|
|
function skillMd(version) {
|
|
return `---\nname: impeccable\nversion: ${version}\nuser-invocable: true\n---\n\nBody.\n`;
|
|
}
|
|
|
|
function writeFixture(root, { plugin, marketplace, subtreePlugin, skill } = {}) {
|
|
const write = (rel, contents) => {
|
|
const abs = path.join(root, rel);
|
|
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
fs.writeFileSync(abs, contents);
|
|
};
|
|
if (plugin !== undefined) {
|
|
write('.claude-plugin/plugin.json', JSON.stringify({ name: 'impeccable', version: plugin }, null, 2));
|
|
}
|
|
if (marketplace !== undefined) {
|
|
write('.claude-plugin/marketplace.json', JSON.stringify({ plugins: [{ name: 'impeccable', version: marketplace }] }, null, 2));
|
|
}
|
|
if (subtreePlugin !== undefined) {
|
|
write('plugin/.claude-plugin/plugin.json', JSON.stringify({ name: 'impeccable', version: subtreePlugin, skills: './skills/' }, null, 2));
|
|
}
|
|
if (skill !== undefined) {
|
|
write('plugin/skills/impeccable/SKILL.md', skillMd(skill));
|
|
}
|
|
}
|
|
|
|
describe('collectPluginVersions', () => {
|
|
let root;
|
|
beforeEach(() => {
|
|
root = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-ver-'));
|
|
});
|
|
afterEach(() => {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
test('no mismatches when every version agrees', () => {
|
|
writeFixture(root, { plugin: '3.7.1', marketplace: '3.7.1', subtreePlugin: '3.7.1', skill: '3.7.1' });
|
|
const { source, mismatches } = collectPluginVersions(root);
|
|
expect(source).toBe('3.7.1');
|
|
expect(mismatches).toEqual([]);
|
|
});
|
|
|
|
test('flags a lagging marketplace.json (the half the sync workflow cannot repair)', () => {
|
|
writeFixture(root, { plugin: '3.7.1', marketplace: '3.1.1', subtreePlugin: '3.7.1', skill: '3.7.1' });
|
|
const { mismatches } = collectPluginVersions(root);
|
|
expect(mismatches).toEqual([
|
|
{ relPath: '.claude-plugin/marketplace.json', found: '3.1.1', expected: '3.7.1' },
|
|
]);
|
|
});
|
|
|
|
test('flags a stale ./plugin subtree manifest', () => {
|
|
writeFixture(root, { plugin: '3.7.1', marketplace: '3.7.1', subtreePlugin: '3.6.0', skill: '3.7.1' });
|
|
const { mismatches } = collectPluginVersions(root);
|
|
expect(mismatches).toEqual([
|
|
{ relPath: 'plugin/.claude-plugin/plugin.json', found: '3.6.0', expected: '3.7.1' },
|
|
]);
|
|
});
|
|
|
|
test('flags a stale bundled SKILL.md frontmatter version', () => {
|
|
writeFixture(root, { plugin: '3.7.1', marketplace: '3.7.1', subtreePlugin: '3.7.1', skill: '3.1.1' });
|
|
const { mismatches } = collectPluginVersions(root);
|
|
expect(mismatches).toEqual([
|
|
{ relPath: 'plugin/skills/impeccable/SKILL.md', found: '3.1.1', expected: '3.7.1' },
|
|
]);
|
|
});
|
|
|
|
test('reports every drifted file at once', () => {
|
|
writeFixture(root, { plugin: '3.7.1', marketplace: '3.1.1', subtreePlugin: '3.5.0', skill: '3.1.1' });
|
|
const { mismatches } = collectPluginVersions(root);
|
|
expect(mismatches.map((m) => m.relPath)).toEqual([
|
|
'.claude-plugin/marketplace.json',
|
|
'plugin/.claude-plugin/plugin.json',
|
|
'plugin/skills/impeccable/SKILL.md',
|
|
]);
|
|
});
|
|
|
|
test('skips files that do not exist instead of throwing', () => {
|
|
writeFixture(root, { plugin: '3.7.1' }); // only root manifest present
|
|
const { source, checked, mismatches, errors } = collectPluginVersions(root);
|
|
expect(source).toBe('3.7.1');
|
|
expect(checked).toEqual([]);
|
|
expect(mismatches).toEqual([]);
|
|
expect(errors).toEqual([]);
|
|
});
|
|
|
|
test('returns a null source with no errors when root plugin.json is absent', () => {
|
|
const { source, mismatches, errors } = collectPluginVersions(root);
|
|
expect(source).toBeNull();
|
|
expect(mismatches).toEqual([]);
|
|
expect(errors).toEqual([]);
|
|
});
|
|
|
|
test('reports a malformed checked manifest as an error instead of throwing', () => {
|
|
writeFixture(root, { plugin: '3.7.1', subtreePlugin: '3.7.1', skill: '3.7.1' });
|
|
// marketplace.json half-edited mid-bump: invalid JSON.
|
|
fs.writeFileSync(path.join(root, '.claude-plugin/marketplace.json'), '{ "plugins": [ { "version": ');
|
|
const { mismatches, errors } = collectPluginVersions(root);
|
|
expect(mismatches).toEqual([]);
|
|
expect(errors).toHaveLength(1);
|
|
expect(errors[0].relPath).toBe('.claude-plugin/marketplace.json');
|
|
expect(errors[0].reason).toMatch(/parse/i);
|
|
});
|
|
|
|
test('reports a malformed root plugin.json as an error, not a thrown stack', () => {
|
|
fs.mkdirSync(path.join(root, '.claude-plugin'), { recursive: true });
|
|
fs.writeFileSync(path.join(root, '.claude-plugin/plugin.json'), '{ not json');
|
|
const { source, errors } = collectPluginVersions(root);
|
|
expect(source).toBeNull();
|
|
expect(errors).toHaveLength(1);
|
|
expect(errors[0].relPath).toBe('.claude-plugin/plugin.json');
|
|
expect(errors[0].reason).toMatch(/parse/i);
|
|
});
|
|
|
|
test('flags a root plugin.json that exists but has no version field', () => {
|
|
fs.mkdirSync(path.join(root, '.claude-plugin'), { recursive: true });
|
|
fs.writeFileSync(path.join(root, '.claude-plugin/plugin.json'), JSON.stringify({ name: 'impeccable' }));
|
|
const { source, errors } = collectPluginVersions(root);
|
|
// source stays null, but it is reported as an error rather than silently passing.
|
|
expect(source).toBeNull();
|
|
expect(errors).toEqual([{ relPath: '.claude-plugin/plugin.json', reason: 'missing "version" field' }]);
|
|
});
|
|
});
|
|
|
|
describe('readSkillFrontmatterVersion', () => {
|
|
test('reads an unquoted version', () => {
|
|
expect(readSkillFrontmatterVersion(skillMd('3.7.1'))).toBe('3.7.1');
|
|
});
|
|
|
|
test('strips surrounding quotes', () => {
|
|
expect(readSkillFrontmatterVersion('---\nversion: "3.7.1"\n---\n')).toBe('3.7.1');
|
|
});
|
|
|
|
test('returns null when there is no frontmatter block', () => {
|
|
expect(readSkillFrontmatterVersion('no frontmatter here')).toBeNull();
|
|
});
|
|
|
|
test('reads a version from CRLF-encoded frontmatter', () => {
|
|
const crlf = '---\r\nname: impeccable\r\nversion: 3.7.1\r\nuser-invocable: true\r\n---\r\n\r\nBody.\r\n';
|
|
expect(readSkillFrontmatterVersion(crlf)).toBe('3.7.1');
|
|
});
|
|
});
|
|
|
|
describe('collectPluginVersions with CRLF line endings', () => {
|
|
let root;
|
|
beforeEach(() => {
|
|
root = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-ver-crlf-'));
|
|
});
|
|
afterEach(() => {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
test('a CRLF-saved SKILL.md at the right version is not a false mismatch', () => {
|
|
writeFixture(root, { plugin: '3.7.1', marketplace: '3.7.1', subtreePlugin: '3.7.1', skill: '3.7.1' });
|
|
// Re-save the bundled SKILL.md with CRLF line endings.
|
|
const skillPath = path.join(root, 'plugin/skills/impeccable/SKILL.md');
|
|
fs.writeFileSync(skillPath, fs.readFileSync(skillPath, 'utf-8').replace(/\n/g, '\r\n'));
|
|
const { mismatches, errors } = collectPluginVersions(root);
|
|
expect(mismatches).toEqual([]);
|
|
expect(errors).toEqual([]);
|
|
});
|
|
});
|