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.
319 lines
11 KiB
JavaScript
319 lines
11 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Read hooks/hooks.json together with its sibling hooks/hooks.metadata.json.
|
|
*
|
|
* Claude Code validates a plugin's hooks.json against its own schema and warns
|
|
* about every key it does not recognise, so ECC's stable matcher ids and
|
|
* human-readable descriptions cannot live in that file. They are kept in a
|
|
* sidecar keyed by event name and aligned with hooks.json entry order, and
|
|
* merged back here so the rest of ECC keeps seeing one object with `id` and
|
|
* `description` on each matcher entry.
|
|
*
|
|
* Index alignment alone cannot tell a reordered hooks.json from a correct one,
|
|
* so every sidecar entry also carries a fingerprint of the matcher entry it
|
|
* describes. A mismatch means the two files drifted apart.
|
|
*/
|
|
|
|
const crypto = require('crypto');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const HOOKS_FILENAME = 'hooks.json';
|
|
const METADATA_FILENAME = 'hooks.metadata.json';
|
|
const FINGERPRINT_LENGTH = 12;
|
|
const FINGERPRINT_PATTERN = /^[0-9a-f]{12}$/;
|
|
|
|
function readJsonObject(filePath, label) {
|
|
let raw;
|
|
try {
|
|
raw = fs.readFileSync(filePath, 'utf8');
|
|
} catch (error) {
|
|
throw new Error(`Unable to read ${label} at ${filePath}: ${error.message}`);
|
|
}
|
|
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(raw);
|
|
} catch (error) {
|
|
throw new Error(`Invalid JSON in ${label} at ${filePath}: ${error.message}`);
|
|
}
|
|
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
throw new Error(`Invalid ${label} at ${filePath}: expected a JSON object`);
|
|
}
|
|
|
|
return parsed;
|
|
}
|
|
|
|
function metadataPathFor(hooksPath) {
|
|
return path.join(path.dirname(hooksPath), METADATA_FILENAME);
|
|
}
|
|
|
|
/**
|
|
* JSON.stringify with object keys sorted, so a fingerprint does not change when
|
|
* someone reorders the keys inside a hook object.
|
|
*/
|
|
function stableStringify(value) {
|
|
if (Array.isArray(value)) {
|
|
return `[${value.map(stableStringify).join(',')}]`;
|
|
}
|
|
if (value && typeof value === 'object') {
|
|
return `{${Object.keys(value).sort().map(
|
|
key => `${JSON.stringify(key)}:${stableStringify(value[key])}`
|
|
).join(',')}}`;
|
|
}
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
/**
|
|
* Fingerprint the parts of a hooks.json matcher entry that identify it: its
|
|
* matcher and its hook commands. Ids and descriptions are excluded so the
|
|
* fingerprint is the same whether or not metadata has been merged in.
|
|
*
|
|
* @param {object} entry - A matcher entry from hooks.json.
|
|
* @returns {string} short hex digest
|
|
*/
|
|
function fingerprintHookEntry(entry) {
|
|
const subject = {
|
|
matcher: entry && 'matcher' in entry ? entry.matcher : null,
|
|
hooks: entry && Array.isArray(entry.hooks) ? entry.hooks : [],
|
|
};
|
|
return crypto.createHash('sha256')
|
|
.update(stableStringify(subject))
|
|
.digest('hex')
|
|
.slice(0, FINGERPRINT_LENGTH);
|
|
}
|
|
|
|
function eventsOf(hooksConfig) {
|
|
return hooksConfig && typeof hooksConfig.hooks === 'object' && hooksConfig.hooks
|
|
&& !Array.isArray(hooksConfig.hooks)
|
|
? hooksConfig.hooks
|
|
: null;
|
|
}
|
|
|
|
function metadataEntriesOf(metadata) {
|
|
return metadata && typeof metadata.entries === 'object' && metadata.entries
|
|
&& !Array.isArray(metadata.entries)
|
|
? metadata.entries
|
|
: null;
|
|
}
|
|
|
|
/**
|
|
* Merge sidecar metadata into a parsed hooks.json object.
|
|
*
|
|
* Neither argument is mutated; the returned config shares untouched matcher
|
|
* entries with the input and copies the ones that receive metadata.
|
|
*
|
|
* @param {object} hooksConfig - Parsed hooks.json.
|
|
* @param {object|null} metadata - Parsed hooks.metadata.json, or null when absent.
|
|
* @returns {object} a new hooks configuration with id/description restored.
|
|
*/
|
|
function applyHooksMetadata(hooksConfig, metadata) {
|
|
const events = eventsOf(hooksConfig);
|
|
const entriesByEvent = metadataEntriesOf(metadata);
|
|
if (!events || !entriesByEvent) {
|
|
return hooksConfig;
|
|
}
|
|
|
|
const mergedEvents = {};
|
|
for (const [event, entries] of Object.entries(events)) {
|
|
const eventMetadata = entriesByEvent[event];
|
|
if (!Array.isArray(entries) || !Array.isArray(eventMetadata)) {
|
|
mergedEvents[event] = entries;
|
|
continue;
|
|
}
|
|
|
|
mergedEvents[event] = entries.map((entry, index) => {
|
|
const entryMetadata = eventMetadata[index];
|
|
if (!entry || typeof entry !== 'object') return entry;
|
|
if (!entryMetadata || typeof entryMetadata !== 'object') return entry;
|
|
|
|
const merged = { ...entry };
|
|
if (typeof entryMetadata.id === 'string' && !('id' in entry)) {
|
|
merged.id = entryMetadata.id;
|
|
}
|
|
if (typeof entryMetadata.description === 'string' && !('description' in entry)) {
|
|
merged.description = entryMetadata.description;
|
|
}
|
|
return merged;
|
|
});
|
|
}
|
|
|
|
return { ...hooksConfig, hooks: mergedEvents };
|
|
}
|
|
|
|
/**
|
|
* Report entries whose metadata is missing, misaligned, or bound to a
|
|
* different matcher entry than the one at the same index.
|
|
*
|
|
* @param {object} hooksConfig - Parsed hooks.json.
|
|
* @param {object|null} metadata - Parsed hooks.metadata.json.
|
|
* @returns {string[]} human-readable problems; empty when the sidecar lines up.
|
|
*/
|
|
function findMetadataMismatches(hooksConfig, metadata) {
|
|
const problems = [];
|
|
const idLocations = new Map();
|
|
const events = eventsOf(hooksConfig) || {};
|
|
const entriesByEvent = metadataEntriesOf(metadata) || {};
|
|
|
|
for (const [event, entries] of Object.entries(events)) {
|
|
if (!Array.isArray(entries)) continue;
|
|
const eventMetadata = entriesByEvent[event];
|
|
|
|
if (!Array.isArray(eventMetadata)) {
|
|
problems.push(`${METADATA_FILENAME} is missing entries for event "${event}"`);
|
|
continue;
|
|
}
|
|
if (eventMetadata.length !== entries.length) {
|
|
problems.push(
|
|
`${METADATA_FILENAME} lists ${eventMetadata.length} entr(ies) for event "${event}" `
|
|
+ `but ${HOOKS_FILENAME} has ${entries.length}`
|
|
);
|
|
continue;
|
|
}
|
|
|
|
eventMetadata.forEach((entry, index) => {
|
|
const label = `${METADATA_FILENAME} ${event}[${index}]`;
|
|
if (!entry || typeof entry !== 'object') {
|
|
problems.push(`${label} is not an object`);
|
|
return;
|
|
}
|
|
if (typeof entry.id !== 'string' || entry.id.trim() === '') {
|
|
problems.push(`${label} is missing a non-empty "id"`);
|
|
} else if (idLocations.has(entry.id)) {
|
|
problems.push(`${label} has duplicate id "${entry.id}" already used by ${idLocations.get(entry.id)}`);
|
|
} else {
|
|
idLocations.set(entry.id, label);
|
|
}
|
|
if ('description' in entry && typeof entry.description !== 'string') {
|
|
problems.push(`${label} has a non-string "description"`);
|
|
}
|
|
if (typeof entry.fingerprint !== 'string' || !FINGERPRINT_PATTERN.test(entry.fingerprint)) {
|
|
problems.push(`${label} is missing a valid "fingerprint"`);
|
|
return;
|
|
}
|
|
const expected = fingerprintHookEntry(entries[index]);
|
|
if (entry.fingerprint !== expected) {
|
|
problems.push(
|
|
`${label} (id "${entry.id}") fingerprint ${entry.fingerprint} does not match `
|
|
+ `${HOOKS_FILENAME} ${event}[${index}] (${expected}); the entries were reordered `
|
|
+ 'or the hook command changed - regenerate with '
|
|
+ 'node scripts/ci/validate-hooks.js --update-fingerprints'
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
for (const event of Object.keys(entriesByEvent)) {
|
|
if (!Array.isArray(events[event])) {
|
|
problems.push(`${METADATA_FILENAME} describes event "${event}" which ${HOOKS_FILENAME} does not define`);
|
|
}
|
|
}
|
|
|
|
return problems;
|
|
}
|
|
|
|
/**
|
|
* Return a copy of the sidecar with every fingerprint recomputed from the
|
|
* matcher entry at the same index. Used to refresh the sidecar after hook
|
|
* commands change.
|
|
*
|
|
* @param {object} hooksConfig - Parsed hooks.json.
|
|
* @param {object} metadata - Parsed hooks.metadata.json.
|
|
* @returns {object} a new metadata object
|
|
*/
|
|
function withRefreshedFingerprints(hooksConfig, metadata) {
|
|
const events = eventsOf(hooksConfig) || {};
|
|
const entriesByEvent = metadataEntriesOf(metadata) || {};
|
|
const refreshed = {};
|
|
|
|
// A known fingerprint at another position signals a reorder, not a command
|
|
// edit. Require the author to move its metadata before refreshing anything.
|
|
const positions = new Map();
|
|
for (const [event, entries] of Object.entries(events)) {
|
|
if (!Array.isArray(entries)) continue;
|
|
entries.forEach((entry, index) => {
|
|
const fingerprint = fingerprintHookEntry(entry);
|
|
const locations = positions.get(fingerprint) || [];
|
|
positions.set(fingerprint, [...locations, `${event}[${index}]`]);
|
|
});
|
|
}
|
|
for (const [event, entries] of Object.entries(entriesByEvent)) {
|
|
if (!Array.isArray(entries)) continue;
|
|
entries.forEach((entry, index) => {
|
|
const locations = positions.get(entry?.fingerprint);
|
|
const location = `${event}[${index}]`;
|
|
if (locations && !locations.includes(location)) {
|
|
throw new Error(`Metadata reorder detected at ${location}; move the matching sidecar entry before refreshing fingerprints`);
|
|
}
|
|
});
|
|
}
|
|
|
|
for (const [event, eventMetadata] of Object.entries(entriesByEvent)) {
|
|
const entries = Array.isArray(events[event]) ? events[event] : [];
|
|
refreshed[event] = Array.isArray(eventMetadata)
|
|
? eventMetadata.map((entry, index) => (
|
|
entry && typeof entry === 'object' && index < entries.length
|
|
? { ...entry, fingerprint: fingerprintHookEntry(entries[index]) }
|
|
: entry
|
|
))
|
|
: eventMetadata;
|
|
}
|
|
|
|
return { ...metadata, entries: refreshed };
|
|
}
|
|
|
|
function assertMetadataAligned(hooksConfig, metadata, hooksPath) {
|
|
const mismatches = findMetadataMismatches(hooksConfig, metadata);
|
|
if (mismatches.length > 0) {
|
|
throw new Error(
|
|
`${METADATA_FILENAME} does not line up with ${hooksPath}:\n ${mismatches.join('\n ')}`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Merge a sidecar into a hooks config, rejecting a sidecar that does not line
|
|
* up. Shared by the readers below so a truncated or reordered sidecar fails
|
|
* loudly instead of producing entries with the wrong or missing ids.
|
|
*
|
|
* @param {object} hooksConfig - Parsed hooks.json.
|
|
* @param {object} metadata - Parsed hooks.metadata.json.
|
|
* @param {string} hooksPath - Used in the error message.
|
|
* @returns {object} a new merged hooks configuration
|
|
*/
|
|
function mergeHooksMetadata(hooksConfig, metadata, hooksPath = HOOKS_FILENAME) {
|
|
assertMetadataAligned(hooksConfig, metadata, hooksPath);
|
|
return applyHooksMetadata(hooksConfig, metadata);
|
|
}
|
|
|
|
/**
|
|
* Read hooks.json and return it with sidecar metadata merged in.
|
|
*
|
|
* @param {string} hooksPath - Path to hooks/hooks.json.
|
|
* @param {string} [label] - Label used in error messages.
|
|
* @returns {object} the merged hooks configuration.
|
|
*/
|
|
function readHooksConfig(hooksPath, label = HOOKS_FILENAME) {
|
|
const hooksConfig = readJsonObject(hooksPath, label);
|
|
const metadataPath = metadataPathFor(hooksPath);
|
|
if (!fs.existsSync(metadataPath)) {
|
|
return hooksConfig;
|
|
}
|
|
return mergeHooksMetadata(hooksConfig, readJsonObject(metadataPath, METADATA_FILENAME), hooksPath);
|
|
}
|
|
|
|
module.exports = {
|
|
HOOKS_FILENAME,
|
|
METADATA_FILENAME,
|
|
applyHooksMetadata,
|
|
findMetadataMismatches,
|
|
fingerprintHookEntry,
|
|
mergeHooksMetadata,
|
|
metadataPathFor,
|
|
readHooksConfig,
|
|
readJsonObject,
|
|
withRefreshedFingerprints,
|
|
};
|