Files
ECC/tests/hooks/hooks-metadata.test.js
T
zpearce-2814andGitHub 1ac07903ec fix(hooks): keep hooks.json within Claude Code's schema
Move stable hook metadata to a validated sidecar while preserving hook commands and installer identity. Reject moved fingerprints and duplicate IDs, and validate before updating metadata. Independent local review passed at c315271624a1fd055b992f2bff889ad2a0ff8a6b; CI run 34678210149 passed. Rollback: revert this squash commit.
2026-09-12 02:59:02 -04:00

294 lines
12 KiB
JavaScript

/**
* Tests for the hooks.json / hooks.metadata.json split.
*
* Claude Code validates a plugin's hooks.json against its own schema and prints
* every key it does not recognise when the plugin loads. These tests keep the
* unknown keys out of hooks.json and keep the sidecar aligned with it.
*
* Run with: node tests/hooks/hooks-metadata.test.js
*/
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const {
applyHooksMetadata,
findMetadataMismatches,
fingerprintHookEntry,
metadataPathFor,
readHooksConfig,
withRefreshedFingerprints,
} = require('../../scripts/lib/hooks-config');
const REPO_ROOT = path.resolve(__dirname, '../..');
const HOOKS_PATH = path.join(REPO_ROOT, 'hooks', 'hooks.json');
const METADATA_PATH = metadataPathFor(HOOKS_PATH);
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
function eachMatcher(hooksConfig, visit) {
for (const [event, entries] of Object.entries(hooksConfig.hooks || {})) {
(entries || []).forEach((entry, index) => visit(entry, `${event}[${index}]`));
}
}
const tests = [];
function test(name, fn) {
tests.push({ name, fn });
}
test('hooks.json does not declare $schema', () => {
const hooksConfig = readJson(HOOKS_PATH);
assert.ok(
!('$schema' in hooksConfig),
'hooks.json must not define "$schema" - Claude Code reports it as an unknown key'
);
});
test('hooks.json matcher entries carry no id or description', () => {
const hooksConfig = readJson(HOOKS_PATH);
eachMatcher(hooksConfig, (entry, label) => {
assert.ok(!('id' in entry), `${label} must not define "id" - it belongs in hooks.metadata.json`);
assert.ok(
!('description' in entry),
`${label} must not define "description" - it belongs in hooks.metadata.json`
);
});
});
test('metadata sidecar exists and lines up with hooks.json', () => {
assert.ok(fs.existsSync(METADATA_PATH), 'hooks/hooks.metadata.json is missing');
const mismatches = findMetadataMismatches(readJson(HOOKS_PATH), readJson(METADATA_PATH));
assert.deepStrictEqual(mismatches, [], `metadata is misaligned:\n${mismatches.join('\n')}`);
});
test('every matcher entry has a unique id after merging', () => {
const merged = readHooksConfig(HOOKS_PATH);
const seen = new Map();
let count = 0;
eachMatcher(merged, (entry, label) => {
count += 1;
assert.ok(
typeof entry.id === 'string' && entry.id.trim() !== '',
`${label} has no id after merging metadata`
);
assert.ok(!seen.has(entry.id), `duplicate id "${entry.id}" at ${label} and ${seen.get(entry.id)}`);
seen.set(entry.id, label);
});
assert.ok(count > 0, 'expected at least one matcher entry');
});
test('merging leaves hook commands untouched', () => {
const raw = readJson(HOOKS_PATH);
const merged = readHooksConfig(HOOKS_PATH);
const commandsOf = config => Object.entries(config.hooks || {}).flatMap(([event, entries]) => (
(entries || []).flatMap((entry, index) => (entry.hooks || []).map(
(hook, hookIndex) => `${event}[${index}].hooks[${hookIndex}]:${JSON.stringify(hook)}`
))
));
assert.deepStrictEqual(commandsOf(merged), commandsOf(raw));
});
test('applyHooksMetadata does not overwrite an id already present', () => {
const hooksConfig = { hooks: { PreToolUse: [{ id: 'existing', matcher: 'Bash', hooks: [] }] } };
const merged = applyHooksMetadata(hooksConfig, { entries: { PreToolUse: [{ id: 'from-sidecar' }] } });
assert.strictEqual(merged.hooks.PreToolUse[0].id, 'existing');
});
test('applyHooksMetadata returns a new config and leaves its inputs untouched', () => {
const entry = { matcher: 'Bash', hooks: [{ type: 'command', command: 'node a.js' }] };
const hooksConfig = { hooks: { PreToolUse: [entry] } };
const metadata = { entries: { PreToolUse: [{ id: 'a', description: 'A' }] } };
const merged = applyHooksMetadata(hooksConfig, metadata);
assert.notStrictEqual(merged, hooksConfig);
assert.notStrictEqual(merged.hooks.PreToolUse[0], entry);
assert.deepStrictEqual(merged.hooks.PreToolUse[0], { ...entry, id: 'a', description: 'A' });
assert.deepStrictEqual(hooksConfig, { hooks: { PreToolUse: [entry] } });
assert.ok(!('id' in entry) && !('description' in entry), 'input entry must not be mutated');
assert.strictEqual(merged.hooks.PreToolUse[0].hooks, entry.hooks, 'untouched nested data is shared');
});
const alpha = { matcher: 'Bash', hooks: [{ type: 'command', command: 'node alpha.js' }] };
const beta = { matcher: 'Bash', hooks: [{ type: 'command', command: 'node beta.js' }] };
const alphaMeta = { id: 'a', fingerprint: fingerprintHookEntry(alpha) };
const betaMeta = { id: 'b', fingerprint: fingerprintHookEntry(beta) };
test('findMetadataMismatches reports length and coverage problems', () => {
const hooksConfig = { hooks: { PreToolUse: [alpha, beta] } };
assert.strictEqual(findMetadataMismatches(hooksConfig, { entries: {} }).length, 1);
assert.strictEqual(
findMetadataMismatches(hooksConfig, { entries: { PreToolUse: [alphaMeta] } }).length,
1
);
assert.strictEqual(
findMetadataMismatches(hooksConfig, {
entries: { PreToolUse: [alphaMeta, { ...betaMeta, id: '' }] },
}).length,
1
);
assert.strictEqual(
findMetadataMismatches(hooksConfig, {
entries: { PreToolUse: [alphaMeta, { ...betaMeta, description: 1 }] },
}).length,
1
);
assert.strictEqual(
findMetadataMismatches(hooksConfig, {
entries: { PreToolUse: [alphaMeta, betaMeta], Stop: [] },
}).length,
1
);
assert.deepStrictEqual(
findMetadataMismatches(hooksConfig, { entries: { PreToolUse: [alphaMeta, betaMeta] } }),
[]
);
});
test('findMetadataMismatches detects reordered entries and missing fingerprints', () => {
const hooksConfig = { hooks: { PreToolUse: [alpha, beta] } };
const reordered = findMetadataMismatches(hooksConfig, { entries: { PreToolUse: [betaMeta, alphaMeta] } });
assert.strictEqual(reordered.length, 2, 'each swapped entry is reported');
assert.match(reordered[0], /PreToolUse\[0\] \(id "b"\) fingerprint .* does not match/);
const changed = findMetadataMismatches(
{ hooks: { PreToolUse: [alpha, { ...beta, matcher: 'Write' }] } },
{ entries: { PreToolUse: [alphaMeta, betaMeta] } }
);
assert.strictEqual(changed.length, 1, 'a changed matcher invalidates the fingerprint');
const missing = findMetadataMismatches(hooksConfig, {
entries: { PreToolUse: [{ id: 'a' }, { id: 'b', fingerprint: 'nope' }] },
});
assert.strictEqual(missing.length, 2);
assert.match(missing[0], /missing a valid "fingerprint"/);
});
test('fingerprintHookEntry ignores id, description, and key order', () => {
const base = fingerprintHookEntry(alpha);
assert.match(base, /^[0-9a-f]{12}$/);
assert.strictEqual(fingerprintHookEntry({ ...alpha, id: 'x', description: 'y' }), base);
assert.strictEqual(
fingerprintHookEntry({ hooks: [{ command: 'node alpha.js', type: 'command' }], matcher: 'Bash' }),
base
);
assert.notStrictEqual(fingerprintHookEntry(beta), base);
});
test('withRefreshedFingerprints rewrites fingerprints without touching ids', () => {
const hooksConfig = { hooks: { PreToolUse: [alpha, beta] } };
const stale = {
$schema: 's',
entries: { PreToolUse: [{ id: 'a', fingerprint: '000000000000' }, { id: 'b' }] },
};
const refreshed = withRefreshedFingerprints(hooksConfig, stale);
assert.deepStrictEqual(refreshed, { $schema: 's', entries: { PreToolUse: [alphaMeta, betaMeta] } });
assert.deepStrictEqual(findMetadataMismatches(hooksConfig, refreshed), []);
assert.strictEqual(stale.entries.PreToolUse[0].fingerprint, '000000000000', 'input is not mutated');
});
test('readHooksConfig rejects a sidecar that does not line up', () => {
const tempDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'ecc-hooks-'));
const tempHooks = path.join(tempDir, 'hooks.json');
fs.writeFileSync(tempHooks, JSON.stringify({ hooks: { PreToolUse: [alpha, beta] } }));
fs.writeFileSync(
metadataPathFor(tempHooks),
JSON.stringify({ entries: { PreToolUse: [betaMeta, alphaMeta] } })
);
try {
assert.throws(() => readHooksConfig(tempHooks), /does not line up with .*hooks\.json[\s\S]*fingerprint/);
fs.writeFileSync(
metadataPathFor(tempHooks),
JSON.stringify({ entries: { PreToolUse: [alphaMeta, betaMeta] } })
);
const merged = readHooksConfig(tempHooks);
assert.deepStrictEqual(merged.hooks.PreToolUse.map(entry => entry.id), ['a', 'b']);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('readHooksConfig returns raw config when the sidecar is absent', () => {
const tempDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'ecc-hooks-'));
const tempHooks = path.join(tempDir, 'hooks.json');
fs.writeFileSync(tempHooks, JSON.stringify({ hooks: { Stop: [{ hooks: [] }] } }));
try {
const config = readHooksConfig(tempHooks);
assert.deepStrictEqual(config, { hooks: { Stop: [{ hooks: [] }] } });
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('failed refresh validation preserves the original sidecar bytes', () => {
const root = fs.mkdtempSync(path.join(require('os').tmpdir(), 'ecc-metadata-refresh-'));
try {
for (const relative of ['scripts/ci/validate-hooks.js', 'scripts/lib/hooks-config.js',
'schemas/hooks.schema.json', 'schemas/hooks-metadata.schema.json']) {
const destination = path.join(root, relative);
fs.mkdirSync(path.dirname(destination), { recursive: true });
fs.copyFileSync(path.join(REPO_ROOT, relative), destination);
}
fs.mkdirSync(path.join(root, 'hooks'));
fs.writeFileSync(path.join(root, 'hooks/hooks.json'), JSON.stringify({ hooks: { PreToolUse: [alpha] } }));
const sidecar = path.join(root, 'hooks/hooks.metadata.json');
const original = JSON.stringify({ entries: { PreToolUse: [{ ...alphaMeta, id: '', fingerprint: '000000000000' }] } });
fs.writeFileSync(sidecar, original);
const result = require('child_process').spawnSync(process.execPath,
[path.join(root, 'scripts/ci/validate-hooks.js'), '--update-fingerprints'], {
encoding: 'utf8', env: { ...process.env, NODE_PATH: path.join(REPO_ROOT, 'node_modules') },
});
assert.strictEqual(result.status, 1, result.stderr);
assert.match(result.stderr, /id|non-empty/);
assert.strictEqual(fs.readFileSync(sidecar, 'utf8'), original);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('refresh refuses reordered hooks instead of rebinding stable ids', () => {
const config = { hooks: { PreToolUse: [beta, alpha] } };
const metadata = { entries: { PreToolUse: [alphaMeta, betaMeta] } };
assert.throws(() => withRefreshedFingerprints(config, metadata), /reorder/i);
assert.deepStrictEqual(metadata.entries.PreToolUse, [alphaMeta, betaMeta]);
});
test('alignment rejects duplicate ids across events', () => {
const config = { hooks: { PreToolUse: [alpha], PostToolUse: [beta] } };
const metadata = { entries: {
PreToolUse: [alphaMeta], PostToolUse: [{ ...betaMeta, id: alphaMeta.id }],
} };
assert.ok(findMetadataMismatches(config, metadata).some(problem =>
/duplicate/.test(problem) && /PreToolUse/.test(problem) && /PostToolUse/.test(problem)));
});
let failures = 0;
for (const { name, fn } of tests) {
try {
fn();
console.log(` PASS ${name}`);
} catch (error) {
failures += 1;
console.error(` FAIL ${name}`);
console.error(` ${error.message}`);
}
}
console.log(`\nResults: Passed: ${tests.length - failures}, Failed: ${failures}`);
process.exit(failures === 0 ? 0 : 1);