Files
msd-core/tests/bug-1754-js-hook-guard.test.cjs
Tom Boucher f11462e58e refactor(#1067): phase 5f-1b — extract the settings-json hook block (applySettingsJsonHooks) — ADR-857/1016 (#1075)
* refactor(#1067): promote referencesHook to runtime-hooks-surface module scope

referencesHook was declared as a local function inside install() but also
called in finishInstall() (module scope), meaning JS hoisting was the only
thing making it work from finishInstall. Move it to src/runtime-hooks-surface.cts,
export it, and have both call sites in install.js use the module's copy.

This is the prerequisite for COMMIT 2 (applySettingsJsonHooks extraction)
per ADR-857 phase 5f-1b.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(#1067): extract applySettingsJsonHooks to runtime-hooks-surface module

ADR-857 phase 5f-1b: move the ~457-line settings.json hook-registration block
from install() into applySettingsJsonHooks(settings, opts) in
src/runtime-hooks-surface.cts. install() replaces the block with a single call.

Behavior-preserving: all runtime=== guards, isGemini/isQwen/isOpencode/isKilo
derivations, postToolEvent/preToolEvent dialect branches, idempotency checks,
fs.existsSync guards, and console.log/warn messages are verbatim.

Opts bag: 13 fields — runtime, isGlobal, targetDir, postToolEvent,
updateCheckCommand, contextMonitorCommand, promptGuardCommand, readGuardCommand,
readInjectionScannerCommand, configReloadCommand, hookOpts, localCmd,
localShellCmd. preToolEvent computed inside (from runtime). workflowGuardCommand
/ worktreePathGuardCommand / validateCommitCommand / graphifyUpdateCommand /
sessionStateCommand / phaseBoundaryCommand / contextMonitorFile also computed
inside. settings.hooks-only mutations confirmed.

5 source-scan tests updated to read runtime-hooks-surface.cts alongside
install.js (concatenated), so structural regression guards remain valid at
their new canonical location.

install.js: 12700 → 12254 lines (−446).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 19:17:06 -04:00

111 lines
4.9 KiB
JavaScript

/**
* Regression tests for bug #1754
*
* The installer must NOT register .js hook entries in settings.json when the
* corresponding .js file does not exist at the target path. The original bug:
* on fresh installs where hooks/dist/ was missing from the npm package (as in
* v1.32.0), the hook copy step produced no files, yet the registration step
* ran unconditionally for .js hooks — leaving users with "PreToolUse:Bash
* hook error" on every tool invocation.
*
* The .sh hooks already had fs.existsSync() guards (added in #1817). This
* test verifies the same defensive pattern exists for all .js hooks.
*/
'use strict';
const { describe, test, before } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js');
// ADR-857 phase 5f-1b: settings-json hook registration moved to runtime-hooks-surface.cts.
const HOOKS_SURFACE_SRC = path.join(__dirname, '..', 'src', 'runtime-hooks-surface.cts');
const JS_HOOKS = [
{ name: 'gsd-check-update.js', registrationAnchor: 'hasGsdUpdateHook' },
{ name: 'gsd-context-monitor.js', registrationAnchor: 'hasContextMonitorHook' },
{ name: 'gsd-prompt-guard.js', registrationAnchor: 'hasPromptGuardHook' },
{ name: 'gsd-read-guard.js', registrationAnchor: 'hasReadGuardHook' },
{ name: 'gsd-workflow-guard.js', registrationAnchor: 'hasWorkflowGuardHook' },
{ name: 'gsd-worktree-path-guard.js', registrationAnchor: 'hasWorktreePathGuardHook' },
];
describe('bug #1754: .js hook registration guards', () => {
let src;
before(() => {
// ADR-857 phase 5f-1b: hook registration moved to runtime-hooks-surface.cts.
// Concatenate both sources so structural assertions find patterns in either file.
const installSrc = fs.readFileSync(INSTALL_SRC, 'utf-8');
let hooksSurfaceSrc = '';
try { hooksSurfaceSrc = fs.readFileSync(HOOKS_SURFACE_SRC, 'utf-8'); } catch { /* ok */ }
src = installSrc + '\n' + hooksSurfaceSrc;
});
for (const { name, registrationAnchor } of JS_HOOKS) {
describe(`${name} registration`, () => {
test(`install.js checks file existence before registering ${name}`, () => {
// Find the registration block by locating the "has...Hook" variable
const anchorIdx = src.indexOf(registrationAnchor);
assert.ok(
anchorIdx !== -1,
`${registrationAnchor} variable not found in install.js`
);
// Extract a window around the registration block to find the guard
const blockStart = anchorIdx;
const blockEnd = Math.min(src.length, anchorIdx + 1200);
const block = src.slice(blockStart, blockEnd);
// The block must contain an fs.existsSync check for the hook file
assert.ok(
block.includes('fs.existsSync') || block.includes('existsSync'),
`install.js must call fs.existsSync on the target path before registering ${name} ` +
`in settings.json. Without this guard, hooks are registered even when the .js file ` +
`was never copied (the root cause of #1754).`
);
});
test(`install.js emits a warning when ${name} is missing`, () => {
// The hook file name (without extension) should appear in a warning message
const hookBaseName = name.replace('.js', '');
const warnPattern = `Skipped`;
const anchorIdx = src.indexOf(registrationAnchor);
const block = src.slice(anchorIdx, Math.min(src.length, anchorIdx + 1200));
assert.ok(
block.includes(warnPattern) && block.includes(hookBaseName),
`install.js must emit a skip warning when ${name} is not found at the target path`
);
});
});
}
test('all .js hooks use the same guard pattern as .sh hooks', () => {
// Count existsSync calls in the hook registration section.
// There should be guards for all JS hooks plus the existing SH hooks.
// This test ensures new hooks added in the future follow the same pattern.
// ADR-857 phase 5f-1b: registration moved to runtime-hooks-surface.cts so scan the
// full concatenated source (install.js + runtime-hooks-surface.cts) rather than slicing.
const registrationSection = src;
// Count unique hook file existence checks (pattern: path.join(targetDir, 'hooks', 'gsd-*.js'))
const jsGuards = (registrationSection.match(/gsd-[\w-]+\.js.*not found at target/g) || []);
const shGuards = (registrationSection.match(/gsd-[\w-]+\.sh.*not found at target/g) || []);
assert.ok(
jsGuards.length >= JS_HOOKS.length,
`Expected at least ${JS_HOOKS.length} .js hook guards, found ${jsGuards.length}. ` +
`Every .js hook registration must check file existence before registering.`
);
assert.ok(
shGuards.length >= 3,
`Expected at least 3 .sh hook guards (validate-commit, session-state, phase-boundary), ` +
`found ${shGuards.length}.`
);
});
});