mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-18 15:50:25 +02:00
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.
294 lines
12 KiB
JavaScript
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);
|