* feat(#770): register Claude Code lifecycle hooks (SubagentStop/Stop/PreCompact/FileChanged) Wire three new context-tracking events (SubagentStop, Stop, PreCompact) to gsd-context-monitor so context-headroom warnings surface at model-stop and subagent-finalisation moments — not just on PostToolUse. Add a new FileChanged hook (gsd-config-reload.js) that hot-reloads .planning/config.json context mid-session when the user edits it, injecting a config summary as hookSpecificOutput.additionalContext. Updates plugin manifest hooks.json, managed-hooks-registry, installer-migration-report allowlist, and shell-command-projection cleanup tables. Tests: 21 new assertions in enh-770-claude-hook-events.test.cjs; enh-788 and issue-766 test suites updated. Closes #770 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(#770): document newly-registered Claude Code lifecycle hooks Add a Hook coverage table to the Claude Code npm installer section of docs/how-to/install-on-your-runtime.md describing SubagentStop, Stop, PreCompact, and the new FileChanged (gsd-config-reload.js) hook that hot-reloads .planning/config.json mid-session. Also fixes the changeset frontmatter (adds type: Added + pr: 821) so docs-lint can consume the fragment. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#770): add gsd-config-reload.js to INVENTORY.md and regenerate manifest The feat commit added hooks/gsd-config-reload.js but did not bump the Hooks count in docs/INVENTORY.md (14→15) or add the new row, and did not regenerate docs/INVENTORY-MANIFEST.json. Both inventory-counts and inventory-manifest-sync tests failed across the full CI matrix. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#770): make lifecycle-hook tests deterministic on scoped runner Replace the shared hooks/dist/ ensemble setup (ensureHooksDist / teardownHooksDist) in the Claude hook tests with per-test isolation: pre-populate each test's own tmpDir/.claude/hooks/ with stub files and pass installerMigrations:[] to install() so the first-time-baseline migration does not remove the stubs before the copy step can run. Root cause: hooks/dist/ is gitignored and absent on a fresh npm ci. ensureHooksDist() created it and teardownHooksDist() deleted it, but with --test-concurrency=4 both test files ran concurrently as separate Node.js worker processes sharing the same filesystem. One file's afterEach teardown deleted hooks/dist/ while the other file's install() was copying from it, producing an ENOENT (reproduced 2/10 runs locally). The additional issue: even with pre-placed stubs surviving the copy race, the 000-first-time-baseline migration classified hooks/gsd-*.js as bundled-gsd-hook artifacts, auto-removed them, and the copy step never re-ran (hooks/dist/ absent) — leaving contextMonitorFile missing and all hook registrations silently skipped (the 'got: []' symptom). Fix: pre-populate targetDir/hooks/ per-test (isolated temp dir) AND pass installerMigrations:[] so the baseline scan is skipped. The Qwen suites already used this pattern correctly; the Claude suites are aligned to it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#770): ship gsd-config-reload.js by adding it to build-hooks HOOKS_TO_COPY The #770 feature added hooks/gsd-config-reload.js and registered it in MANAGED_HOOKS, the installer, INVENTORY, and the test EXPECTED_ALL_HOOKS list — but never added it to scripts/build-hooks.js HOOKS_TO_COPY. As a result the hook was never copied into hooks/dist/ during the build, so: - the hook would never ship to users (real production bug — the FileChanged config-reload feature was dead-on-arrival), and - install-minimal-hooks.test.cjs #1755 ("all expected hooks are copied from hooks/dist/ to target", ".js hooks are executable after copy", "manifest contains .js hook entries") failed on any environment with a clean checkout (no pre-existing hooks/dist/): coverage, full test macos-22/macos-24, test ubuntu-24. The failures were masked locally only by a stale hooks/dist/ left from a prior build (build-hooks copies into dist without clearing it). On CI's fresh `npm ci` there is no dist, so the omission surfaced. Fix: add 'gsd-config-reload.js' to HOOKS_TO_COPY so build-hooks stages it into hooks/dist/ alongside the other JS hooks. Verified by removing hooks/dist/ and rerunning the full suite green (0 fail). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#770): make config prototype-pollution beforeEach deterministic on scoped runner Root cause: the #663 and alert-#26 prototype-pollution describe blocks seeded .planning/config.json in beforeEach via a bare runGsdTools('config-ensure-section') whose result was discarded. That command runs in a spawned gsd-tools child; on the scoped CI lane (--test-concurrency=4, config.test.cjs scheduled alongside the heavy install/tarball suites that #770 pulled into the targeted set) the child can be transiently killed under resource pressure (non-zero exit, empty stderr — an OS-level kill, not an app error). The swallowed failure left config.json absent, so the first subtest's readConfig() threw ENOENT opening <tmp>/.planning/config.json. Only 1 of 4 subtests failed, confirming a per-invocation transient, not a deterministic miss; the full suite schedules files differently so config.test.cjs did not collide with those heavy neighbors → passed there. Fix: add ensureConfigReady(tmpDir) which retries config-ensure-section on ANY failure or missing file and throws a clear diagnostic if it still cannot create config.json, then use it in both prototype-pollution beforeEach blocks. Setup is now deterministic under load; the #663/alert-#26 security assertions are unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
380 lines
16 KiB
JavaScript
380 lines
16 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Regression tests for issue #766: additive Claude Code plugin manifest.
|
|
*
|
|
* Asserts structural and semantic correctness of:
|
|
* .claude-plugin/plugin.json — plugin manifest
|
|
* hooks/hooks.json — plugin hook wiring
|
|
*/
|
|
|
|
const { test, describe } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { spawnSync } = require('child_process');
|
|
|
|
const ROOT = path.resolve(__dirname, '..');
|
|
const identity = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'package-identity.cjs'));
|
|
const pkg = require(path.join(ROOT, 'package.json'));
|
|
const { MANAGED_HOOKS } = require(path.join(ROOT, 'hooks', 'managed-hooks-registry.cjs'));
|
|
|
|
const PLUGIN_JSON_PATH = path.join(ROOT, '.claude-plugin', 'plugin.json');
|
|
const HOOKS_JSON_PATH = path.join(ROOT, 'hooks', 'hooks.json');
|
|
|
|
// ─── Section A: plugin.json ───────────────────────────────────────────────────
|
|
describe('A: .claude-plugin/plugin.json', () => {
|
|
|
|
let manifest;
|
|
|
|
test('exists and is valid JSON', () => {
|
|
assert.ok(fs.existsSync(PLUGIN_JSON_PATH), '.claude-plugin/plugin.json must exist');
|
|
const raw = fs.readFileSync(PLUGIN_JSON_PATH, 'utf-8');
|
|
manifest = JSON.parse(raw); // throws on invalid JSON
|
|
assert.ok(typeof manifest === 'object' && manifest !== null, 'manifest must be a JSON object');
|
|
});
|
|
|
|
test('name equals identity.binName ("gsd-core")', (t) => {
|
|
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
|
assert.equal(manifest.name, identity.binName, `name should be "${identity.binName}"`);
|
|
});
|
|
|
|
test('name is kebab-case, no colons, spaces, or uppercase', (t) => {
|
|
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
|
assert.match(
|
|
manifest.name,
|
|
/^[a-z0-9]+(?:-[a-z0-9]+)*$/,
|
|
'name must be kebab-case (no colon, space, or uppercase) to be namespace-safe'
|
|
);
|
|
});
|
|
|
|
test('version matches package.json version', (t) => {
|
|
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
|
assert.equal(manifest.version, pkg.version, `.claude-plugin/plugin.json version (${manifest.version}) must match package.json version (${pkg.version}). When bumping the package version, update .claude-plugin/plugin.json \`version\` to match — Claude Code plugin --strict validation requires a version field and the plugin manifest must track the package version. (#766)`);
|
|
});
|
|
|
|
test('repository equals identity.repoUrl', (t) => {
|
|
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
|
assert.equal(manifest.repository, identity.repoUrl, 'repository must equal identity.repoUrl');
|
|
});
|
|
|
|
test('homepage equals identity.repoUrl', (t) => {
|
|
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
|
assert.equal(manifest.homepage, identity.repoUrl, 'homepage must equal identity.repoUrl');
|
|
});
|
|
|
|
test('license matches package.json license', (t) => {
|
|
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
|
assert.equal(manifest.license, pkg.license, 'license must match package.json');
|
|
});
|
|
|
|
test('author.name is a non-empty string', (t) => {
|
|
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
|
assert.ok(
|
|
manifest.author && typeof manifest.author.name === 'string' && manifest.author.name.trim().length > 0,
|
|
'author.name must be a non-empty string'
|
|
);
|
|
});
|
|
|
|
test('commands field is "./commands/gsd/" and that dir exists with at least one .md file', (t) => {
|
|
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
|
assert.equal(manifest.commands, './commands/gsd/', 'commands must be "./commands/gsd/"');
|
|
const resolvedDir = path.resolve(path.dirname(PLUGIN_JSON_PATH), '..', manifest.commands);
|
|
assert.ok(fs.existsSync(resolvedDir), `resolved commands dir must exist: ${resolvedDir}`);
|
|
const mdFiles = fs.readdirSync(resolvedDir).filter(f => f.endsWith('.md'));
|
|
assert.ok(mdFiles.length > 0, `commands dir must contain at least one .md file`);
|
|
});
|
|
|
|
test('hooks field is "./hooks/hooks.json" and that file exists', (t) => {
|
|
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
|
assert.equal(manifest.hooks, './hooks/hooks.json', 'hooks must be "./hooks/hooks.json"');
|
|
const resolvedHooks = path.resolve(path.dirname(PLUGIN_JSON_PATH), '..', manifest.hooks);
|
|
assert.ok(fs.existsSync(resolvedHooks), `resolved hooks file must exist: ${resolvedHooks}`);
|
|
});
|
|
|
|
test('no "$schema" key (intentionally omitted)', (t) => {
|
|
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
|
assert.ok(!Object.prototype.hasOwnProperty.call(manifest, '$schema'), 'plugin.json must NOT contain a $schema key');
|
|
});
|
|
});
|
|
|
|
// ─── Section B: hooks/hooks.json ─────────────────────────────────────────────
|
|
describe('B: hooks/hooks.json', () => {
|
|
|
|
let hooksConfig;
|
|
|
|
test('exists and is valid JSON with top-level "hooks" object', () => {
|
|
assert.ok(fs.existsSync(HOOKS_JSON_PATH), 'hooks/hooks.json must exist');
|
|
const raw = fs.readFileSync(HOOKS_JSON_PATH, 'utf-8');
|
|
hooksConfig = JSON.parse(raw);
|
|
assert.ok(
|
|
typeof hooksConfig === 'object' && hooksConfig !== null &&
|
|
typeof hooksConfig.hooks === 'object' && hooksConfig.hooks !== null,
|
|
'hooks.json must have a top-level "hooks" object'
|
|
);
|
|
});
|
|
|
|
test('every event name is a known Claude Code lifecycle event', (t) => {
|
|
if (!hooksConfig) { t.skip('hooks.json could not be parsed'); return; }
|
|
// Complete set of Claude Code hook events as of #770 (SubagentStop, Stop,
|
|
// PreCompact, FileChanged added in #770; prior set was SessionStart,
|
|
// PreToolUse, PostToolUse from #766).
|
|
const validEvents = new Set([
|
|
'SessionStart', 'PreToolUse', 'PostToolUse',
|
|
'SubagentStop', 'Stop', 'PreCompact', 'FileChanged',
|
|
]);
|
|
for (const eventName of Object.keys(hooksConfig.hooks)) {
|
|
assert.ok(validEvents.has(eventName), `Unknown hook event: "${eventName}"`);
|
|
}
|
|
});
|
|
|
|
test('every hook entry has type "command" and command contains ${CLAUDE_PLUGIN_ROOT}', (t) => {
|
|
if (!hooksConfig) { t.skip('hooks.json could not be parsed'); return; }
|
|
for (const [eventName, eventEntries] of Object.entries(hooksConfig.hooks)) {
|
|
assert.ok(Array.isArray(eventEntries), `Event "${eventName}" must be an array`);
|
|
for (const entry of eventEntries) {
|
|
assert.ok(Array.isArray(entry.hooks), `Entry in "${eventName}" must have a hooks array`);
|
|
for (const hook of entry.hooks) {
|
|
assert.equal(hook.type, 'command', `All hook entries must have type "command" (got "${hook.type}")`);
|
|
assert.ok(
|
|
typeof hook.command === 'string' && hook.command.includes('${CLAUDE_PLUGIN_ROOT}'),
|
|
`Hook command must contain "\${CLAUDE_PLUGIN_ROOT}": ${hook.command}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
test('every referenced script file exists on disk and its basename is in MANAGED_HOOKS', (t) => {
|
|
if (!hooksConfig) { t.skip('hooks.json could not be parsed'); return; }
|
|
// Extract script path: substring after ${CLAUDE_PLUGIN_ROOT}/ up to next "
|
|
const scriptPathRe = /\$\{CLAUDE_PLUGIN_ROOT\}\/([^"]+)/g;
|
|
const allScripts = [];
|
|
for (const eventEntries of Object.values(hooksConfig.hooks)) {
|
|
for (const entry of eventEntries) {
|
|
for (const hook of entry.hooks) {
|
|
const matches = [...hook.command.matchAll(scriptPathRe)];
|
|
for (const m of matches) {
|
|
allScripts.push(m[1]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
assert.ok(allScripts.length > 0, 'Should have found at least one script path in hooks.json');
|
|
for (const scriptPath of allScripts) {
|
|
const fullPath = path.join(ROOT, scriptPath);
|
|
assert.ok(fs.existsSync(fullPath), `Script referenced in hooks.json does not exist on disk: ${fullPath}`);
|
|
const basename = path.basename(scriptPath);
|
|
assert.ok(
|
|
MANAGED_HOOKS.includes(basename),
|
|
`Script basename "${basename}" is not listed in hooks/managed-hooks-registry.cjs MANAGED_HOOKS`
|
|
);
|
|
}
|
|
});
|
|
|
|
test('all six always-on hooks are wired', (t) => {
|
|
if (!hooksConfig) { t.skip('hooks.json could not be parsed'); return; }
|
|
const REQUIRED_HOOKS = [
|
|
'gsd-check-update.js',
|
|
'gsd-prompt-guard.js',
|
|
'gsd-read-guard.js',
|
|
'gsd-worktree-path-guard.js',
|
|
'gsd-context-monitor.js',
|
|
'gsd-read-injection-scanner.js',
|
|
];
|
|
// Collect all basenames wired in hooks.json
|
|
const wiredBasenames = new Set();
|
|
const scriptPathRe = /\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/([^"]+)/g;
|
|
for (const eventEntries of Object.values(hooksConfig.hooks)) {
|
|
for (const entry of eventEntries) {
|
|
for (const hook of entry.hooks) {
|
|
const matches = [...hook.command.matchAll(scriptPathRe)];
|
|
for (const m of matches) {
|
|
wiredBasenames.add(m[1]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for (const required of REQUIRED_HOOKS) {
|
|
assert.ok(wiredBasenames.has(required), `Required hook "${required}" is not wired in hooks/hooks.json`);
|
|
}
|
|
});
|
|
|
|
test('gsd-context-monitor.js entry has timeout === 10', (t) => {
|
|
if (!hooksConfig) { t.skip('hooks.json could not be parsed'); return; }
|
|
let found = false;
|
|
for (const eventEntries of Object.values(hooksConfig.hooks)) {
|
|
for (const entry of eventEntries) {
|
|
for (const hook of entry.hooks) {
|
|
if (hook.command && hook.command.includes('gsd-context-monitor.js')) {
|
|
found = true;
|
|
assert.equal(hook.timeout, 10, 'gsd-context-monitor.js must have timeout === 10');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
assert.ok(found, 'gsd-context-monitor.js entry was not found in hooks.json');
|
|
});
|
|
});
|
|
|
|
// ─── Section C: Optional CLI integration test ─────────────────────────────────
|
|
describe('C: claude plugin validate (CLI integration)', () => {
|
|
|
|
const claudeAvailable = (() => {
|
|
try {
|
|
const result = spawnSync('claude', ['--version'], { encoding: 'utf-8', timeout: 5000 });
|
|
return result.status === 0;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
})();
|
|
|
|
test(
|
|
'claude plugin validate . --strict exits 0 (skip if claude not on PATH)',
|
|
{ skip: !claudeAvailable ? 'claude binary not available on PATH' : false },
|
|
() => {
|
|
const result = spawnSync('claude', ['plugin', 'validate', '.', '--strict'], {
|
|
cwd: ROOT,
|
|
encoding: 'utf-8',
|
|
timeout: 15000,
|
|
});
|
|
assert.equal(
|
|
result.status,
|
|
0,
|
|
`claude plugin validate . --strict exited with ${result.status}.\nstdout: ${result.stdout}\nstderr: ${result.stderr}`
|
|
);
|
|
}
|
|
);
|
|
});
|
|
|
|
// ─── Section D: Always-on hook contract (drift guard) ────────────────────────
|
|
describe('D: always-on hook contract drift guard', () => {
|
|
|
|
/**
|
|
* Parses hooks.json and builds a map:
|
|
* event -> matcher (or '' for no-matcher) -> [{script, timeout}]
|
|
*
|
|
* script: basename of the .js/.sh file referenced in the command string
|
|
* timeout: numeric value from hook.timeout, or undefined if absent
|
|
*/
|
|
function buildHookMap() {
|
|
const raw = fs.readFileSync(HOOKS_JSON_PATH, 'utf-8');
|
|
const hooksConfig = JSON.parse(raw);
|
|
const scriptRe = /\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/([^\s"]+)/;
|
|
const map = {};
|
|
for (const [eventName, eventEntries] of Object.entries(hooksConfig.hooks)) {
|
|
map[eventName] = map[eventName] || {};
|
|
for (const entry of eventEntries) {
|
|
const matcher = entry.matcher || '';
|
|
map[eventName][matcher] = map[eventName][matcher] || [];
|
|
for (const hook of entry.hooks) {
|
|
const m = hook.command.match(scriptRe);
|
|
if (m) {
|
|
map[eventName][matcher].push({
|
|
script: m[1],
|
|
timeout: hook.timeout,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return map;
|
|
}
|
|
|
|
test('SessionStart: exactly one no-matcher group with gsd-check-update.js and no timeout', () => {
|
|
const map = buildHookMap();
|
|
const groups = map['SessionStart'];
|
|
assert.ok(groups, 'SessionStart must be present in hooks.json');
|
|
// There must be exactly one entry group (key '' = no matcher)
|
|
const noMatcherHooks = groups[''];
|
|
assert.ok(
|
|
Array.isArray(noMatcherHooks) && noMatcherHooks.length === 1,
|
|
`SessionStart no-matcher group must contain exactly one hook; got: ${JSON.stringify(noMatcherHooks)}`
|
|
);
|
|
const h = noMatcherHooks[0];
|
|
assert.equal(h.script, 'gsd-check-update.js', 'SessionStart hook must be gsd-check-update.js');
|
|
assert.equal(h.timeout, undefined, 'gsd-check-update.js must NOT have a timeout field');
|
|
});
|
|
|
|
test('PreToolUse Write|Edit group: gsd-prompt-guard.js (timeout 5) + gsd-read-guard.js (timeout 5)', () => {
|
|
const map = buildHookMap();
|
|
const groups = map['PreToolUse'];
|
|
assert.ok(groups, 'PreToolUse must be present in hooks.json');
|
|
const hooks = groups['Write|Edit'];
|
|
assert.ok(
|
|
Array.isArray(hooks) && hooks.length === 2,
|
|
`PreToolUse Write|Edit must have exactly 2 hooks; got: ${JSON.stringify(hooks)}`
|
|
);
|
|
assert.equal(hooks[0].script, 'gsd-prompt-guard.js', 'first hook must be gsd-prompt-guard.js');
|
|
assert.equal(hooks[0].timeout, 5, 'gsd-prompt-guard.js must have timeout 5');
|
|
assert.equal(hooks[1].script, 'gsd-read-guard.js', 'second hook must be gsd-read-guard.js');
|
|
assert.equal(hooks[1].timeout, 5, 'gsd-read-guard.js must have timeout 5');
|
|
});
|
|
|
|
test('PreToolUse Write|Edit|MultiEdit group: gsd-worktree-path-guard.js (timeout 5)', () => {
|
|
const map = buildHookMap();
|
|
const groups = map['PreToolUse'];
|
|
assert.ok(groups, 'PreToolUse must be present in hooks.json');
|
|
const hooks = groups['Write|Edit|MultiEdit'];
|
|
assert.ok(
|
|
Array.isArray(hooks) && hooks.length === 1,
|
|
`PreToolUse Write|Edit|MultiEdit must have exactly 1 hook; got: ${JSON.stringify(hooks)}`
|
|
);
|
|
assert.equal(hooks[0].script, 'gsd-worktree-path-guard.js', 'hook must be gsd-worktree-path-guard.js');
|
|
assert.equal(hooks[0].timeout, 5, 'gsd-worktree-path-guard.js must have timeout 5');
|
|
});
|
|
|
|
test('PostToolUse Bash|Edit|Write|MultiEdit|Agent|Task group: gsd-context-monitor.js (timeout 10)', () => {
|
|
const map = buildHookMap();
|
|
const groups = map['PostToolUse'];
|
|
assert.ok(groups, 'PostToolUse must be present in hooks.json');
|
|
const hooks = groups['Bash|Edit|Write|MultiEdit|Agent|Task'];
|
|
assert.ok(
|
|
Array.isArray(hooks) && hooks.length === 1,
|
|
`PostToolUse Bash|Edit|Write|MultiEdit|Agent|Task must have exactly 1 hook; got: ${JSON.stringify(hooks)}`
|
|
);
|
|
assert.equal(hooks[0].script, 'gsd-context-monitor.js', 'hook must be gsd-context-monitor.js');
|
|
assert.equal(hooks[0].timeout, 10, 'gsd-context-monitor.js must have timeout 10');
|
|
});
|
|
|
|
test('PostToolUse Read group: gsd-read-injection-scanner.js (timeout 5)', () => {
|
|
const map = buildHookMap();
|
|
const groups = map['PostToolUse'];
|
|
assert.ok(groups, 'PostToolUse must be present in hooks.json');
|
|
const hooks = groups['Read'];
|
|
assert.ok(
|
|
Array.isArray(hooks) && hooks.length === 1,
|
|
`PostToolUse Read must have exactly 1 hook; got: ${JSON.stringify(hooks)}`
|
|
);
|
|
assert.equal(hooks[0].script, 'gsd-read-injection-scanner.js', 'hook must be gsd-read-injection-scanner.js');
|
|
assert.equal(hooks[0].timeout, 5, 'gsd-read-injection-scanner.js must have timeout 5');
|
|
});
|
|
});
|
|
|
|
// ─── Section E: Config-gated hooks must be absent from hooks.json ─────────────
|
|
describe('E: config-gated (opt-in) hooks must not appear in hooks.json', () => {
|
|
|
|
const CONFIG_GATED_HOOKS = [
|
|
'gsd-workflow-guard.js',
|
|
'gsd-validate-commit.sh',
|
|
'gsd-graphify-update.sh',
|
|
'gsd-session-state.sh',
|
|
'gsd-phase-boundary.sh',
|
|
'gsd-update-banner.js',
|
|
'gsd-statusline.js',
|
|
'gsd-check-update-worker.js',
|
|
];
|
|
|
|
test('none of the config-gated hook basenames appear in hooks.json command strings', () => {
|
|
const raw = fs.readFileSync(HOOKS_JSON_PATH, 'utf-8');
|
|
// Check raw text — simple and resistant to structure changes
|
|
for (const hookBasename of CONFIG_GATED_HOOKS) {
|
|
assert.ok(
|
|
!raw.includes(hookBasename),
|
|
`Config-gated hook "${hookBasename}" must NOT appear in hooks/hooks.json ` +
|
|
`(it is opt-in and must not run unconditionally on the plugin path)`
|
|
);
|
|
}
|
|
});
|
|
});
|