Files
msd-core/tests/hooks-opt-in.test.cjs
Tom Boucher 107eb8c1d9 feat(#3753): run docs guards on the PR that changes the docs they read (#3787)
A PR whose diff is entirely under docs/ runs zero tests, so a guard whose INPUT
is shipped prose cannot protect the PR lane of the diffs it exists to check. Its
only firing opportunity is after merge, on the shared branch -- which is how next
went red on dacae9273 while the PR that caused it (#3746) was green on every
check.

The docs-lint job in .github/workflows/docs-required.yml -- an ALREADY-REQUIRED
context -- now selects and runs the docs guards that read the specific docs files
the PR changed.

  scripts/docs-guard-registry.cjs    test file -> the docs paths it reads (63)
  scripts/select-docs-guards.cjs     pure (changedPaths, registry) -> test files
  scripts/lint-docs-guard-registration.cjs   drift guard, wired into lint:ci

scripts/ci-test-scope.cjs is NOT touched -- `git diff origin/next --` on it is
empty -- so #764's saving stands and its 21 pinning tests are untouched.

Selection: exact path; trailing-slash directory prefix (boundary-checked --
docs/adrenaline.md does NOT match docs/adr/, which a naive startsWith gets
wrong); and '*' for the 6 entries that walk docs/ generally or read a computed
path. Unknown maps to '*' -- guessing narrow is how a guard silently stops
running. Measured: a typo fix selects 6 of 63; docs/AGENTS.md selects 12;
docs/COMMANDS.md selects 18.

Four things this got wrong first, each found by an independent reviewer or by
probe, and each having been asserted safe in a comment:

1. The registry started as a RULE in ci-test-scope.cjs's RULES, on the theory
   that classify()'s !codeChanged normalization made it inert. True for
   docs-ONLY diffs; false for MIXED docs+code diffs, where codeChanged is true
   and the normalization never runs:

     node scripts/ci-test-scope.cjs --files "docs/a.md src/semver.cts"
       with the RULE:  25 targeted_tests
       origin/next:     3 targeted_tests

   Category error: RULES is the scoped lane's input; a docs-guard registry is a
   lane manifest for a consumer that never calls classify(). Extracted; pinned
   by value.

2. The second attempt was a dedicated workflow with paths: [docs/**]. Such a
   workflow never reports on a non-docs PR, so it can never be a required
   context without hanging every non-docs PR -- and a non-required check does not
   block a merge, so the guard would have been advisory and #3753 unfixed.
   docs-required.yml already has no paths: filter, already supplies the required
   docs-lint context, already computes docs_changed, and already ran one docs
   guard gated on it. Generalizing that step needs no ruleset edit at all.

3. The registry and the drift lint were built from ONE path-segment heuristic, so
   both were blind identically -- and blind at the guard that motivated the issue.
   The reader-call regex required a character BEFORE its keyword, so a callee
   named exactly read( / load( / parse( / doc( / file( / content( could never
   match; and only an INLINE path.join(ROOT,'docs','X.md') argument was caught,
   missing the two-step-via-variable form -- the MAJORITY spelling -- plus
   template literals and concatenation. Detector 1 fired on 14 of ~450 files, so
   35 genuine guards sat unregistered while the lint reported 0 violations,
   including cursor-reviewer (reads docs/COMMANDS.md, asserts
   .includes('--cursor')) and inventory-headings-countfree. The "accepted blind
   spot" this shipped with was the common case, not a fringe.

4. With detection fixed the true population is 115 files: 63 genuine guards, 52
   incidental. Running all 63 in a REQUIRED check on a one-line typo fix is the
   cost #764 exists to avoid -- install.test.cjs is 7840 lines and reads exactly
   one docs file, docs/AGENTS.md, for its frontmatter. Dropping it reproduces the
   bug; running it for a typo elsewhere is waste. Hence the map.

Then a second review round found six more, all fixed here:

- fragment-single-edit-propagation.install.test.cjs was EXEMPTED as
  "overlay fixture only". False: it reads the real docs/registries/eos.json and
  asserts on a registry entry name, and reads the real ADR-0001 and asserts its
  H1. A docs-only PR touching either would have gone green and red next -- #3753
  shipping again, from inside the fix for it. Now registered against both paths,
  and all 52 remaining exemptions were re-audited one by one.
- The SUITES-collision guard compared RAW registry keys, but run-tests.cjs strips
  a leading `tests/` BEFORE its suite check. So it caught 'all' and missed
  'tests/all' -- the only spelling that can actually occur, since every key
  carries the prefix. One typo would have run all 824 test files inside the
  required job. Now normalized the same way run-tests.cjs normalizes.
- The lint failed OPEN on an unreadable tests dir or candidate file: 0 violations,
  ok:true. A guard that cannot read its input must never report success.
- The exemption ratchet gated identity only, so a baselined file that later
  STARTED asserting on shipped docs stayed exempt silently -- 52 permanently blind
  files. The baseline now fingerprints the docs paths each exempted file
  references and fails when that set changes, naming what changed.
- The exemption marker was still honored inside a multi-line template literal in
  the header window. The scanner now tracks template-literal and block-comment
  state.
- `git diff --name-only | grep '^docs/'` silently dropped C-quoted non-ASCII docs
  paths, making docs_changed=false a green zero-guard check. Both call sites now
  pass -c core.quotepath=false.
- The run step was gated on hashFiles(), which a force-committed
  .docs-guard-tests.txt would satisfy. The step now rm -f's both scratch files
  first and gates on an output it sets itself.

Three empty states, deliberately distinct, because conflating them rebuilds
#3753: an empty or malformed registry HARD-FAILS; docs changed with no guard
covering them logs and skips; no docs change is already gated. The middle state
must never be expressed as an empty --files-from, which prints `no tests in suite
"all"` and exits 0 -- a green check that guarded nothing. With the current
registry that state is unreachable, because the six '*' entries always match;
the branch is kept as defensive handling for a future registry and says so.

timeout-minutes: 15 bounds the required job against a hanging fork-supplied test;
it had none. npm ci was added because the job never installed dependencies -- the
previous single-file step got away without it, the registry does not.

docs/contributing/docs-guard-registration.md documents the rule, following its
sibling cross-platform-portability-rules.md, and CONTRIBUTING.md's CI Test
Quality Checks table links to it. It is also load-bearing: without a docs/ file
in the diff this PR would not have triggered its own lane, shipping an
unexercised change to a required check.

One unrelated fix, included because this PR surfaced it and CLAUDE.md forbids
deferring a defect found while working. On this branch's first CI run,
`full test (windows-latest, 24, shard 3/3)` was CANCELLED at exactly 30 minutes;
tests were still passing 0.8s before the cancel, so it is a wall-clock timeout,
not a hang, and a cancelled job reddens `Required tests`.

The cause is not this PR's test file, which costs ~60ms. Shard composition is
unstable: adding ONE file to the unit suite reshuffled 115 of 268 files between
shards, and shard 3 drew a heavier mix. Underneath that is a real pre-existing
defect. tests/ci-test-job-timeout-budget.test.cjs requires every lane's budget to
be >= 1.5x its MEASURED cost -- "a lane that got slower must be re-budgeted, not
excused" -- and its test-full entry recorded 19m from a windows-22 shard. That is
stale. Measured on `next` with none of this PR's changes present: 26m18s (run
32614439702, windows-latest/24 shard 3/3), 23m36s and 23m17s on shard 2/3. So the
lane costs ~26m and the 30-minute cap carried 1.14x headroom, not 1.5x. The gate
had been out of compliance with its own rule; this PR was merely the file
addition that reshuffled shard 3 past the cliff.

Fixed as that file prescribes: measuredMinutes 19 -> 27 with fresh evidence, and
test-full timeout-minutes 30 -> 45. The rule's minimum for 27m is 41; 45 is
deliberately above it because the reshuffle means per-shard worst case moves run
to run, and a budget pinned to the exact minimum would be re-breached by the next
test file anyone adds. Only that one job's timeout changed; test.yml's scope,
matrix and steps are untouched, so #764's saving is unaffected.

Raising that cap let the Windows shard finish (28m45s, inside 45) and uncovered
a real failure the 30-minute cancel had been masking:
`new quick-task branch branches off origin/main (#2916)` died with
`outcome=timed_out exitCode=null`, SIGTERM, at the 15000ms bound.

tests/quick-branching.test.cjs:149 `runStep` runs a `#!/usr/bin/env bash` script
executing MULTIPLE git commands, but was bound to GIT_TIMEOUT_MS (15000) -- the
norm for a SINGLE git plumbing call. tests/helpers/timeouts.cjs already documents
this exact failure and exists to fix it: HOOK_FANOUT_TIMEOUT_MS was created after
PR #3285 recorded "outcome=timed_out exitCode=null at exactly the 15000ms probe
bound while every other lane passed the same commit", and calls that "a bound
sized for the wrong class, not a slow machine". Our failure is that case
verbatim, so both sites move to the class norm rather than to a bigger number.

The same class also failed on `next` itself 21 hours earlier -- run 32608945654,
windows-latest/24 shard 1/3, `plan touching only src/ in a submodule project
keeps worktree isolation ENABLED` -- where tests/worktree-safety.test.cjs:5845
`runGate` fans out to `git config --file .gitmodules` under a hardcoded 30000.
Fixed too, since it is a defect in the tree regardless of which branch surfaced
it.

A survey of the whole tests/ tree found the same class-mismatch at further
bash fan-out sites bound under 60000ms, and the maintainer approved sweeping
them rather than leaving them latent to surface the same way one at a time. 16
fan-out sites across 16 files now use the class norm.

The sweep is class-correctness, not raising numbers until things pass. Sites
were moved ONLY where the bash body demonstrably spawns something (git, node,
npm, a CLI); self-contained shell snippets were left where they are, and are
listed as deliberately unchanged: pure if/printf bodies (copilot-install), pure
array/case builtins (code-review-pipeline-regression:638), a documented
pure-shell gsd_run stub (host-integration), single-process hook calls
(workflow-guard:222/271/302), and a deliberately tight 5000ms fast-check hook
(gsd-write-guard.property). Nothing was lowered. process-seam.test.cjs:513
(literal 300) is untouched on purpose -- it tests timeout BEHAVIOR, so raising
it would destroy what it asserts.

Shared file-level constants were the trap here, and were handled per file rather
than by redefinition: GIT_TIMEOUT_MS has ~15 users in git-base-branch and only 1
is a fan-out; WORKTREE_TIMEOUT_MS has 16 users in worktree.test.cjs and 3 are;
PROBE_TIMEOUT_MS has several in three more files. In each the CALL SITE was
changed and the constant left alone, so no single-plumbing-call site silently
inherited a 60s bound. The one exception is hooks-opt-in.test.cjs, where
HOOK_TIMEOUT_MS has exactly one consumer -- spawnHook, the fan-out itself -- so
redefining it is identical in effect and reads better.

Only two of these sites have actually been observed failing. The rest cite that
shared class and those two run ids rather than inventing evidence of their own.

Co-authored-by: sim <sim@local>
2026-08-23 21:21:21 -04:00

608 lines
25 KiB
JavaScript

// Migrated to typed-IR (#2974): the gsd-session-state.sh and
// gsd-phase-boundary.sh hooks now emit Claude Code SessionStart/PostToolUse
// JSON envelopes ({ hookSpecificOutput: { hookEventName, additionalContext,
// state_present, config_mode | planning_modified, file_path } }) instead of
// plain text. gsd-validate-commit.sh already emitted JSON ({ decision,
// reason }). Tests parse the JSON and assert on typed fields.
/**
* GSD Tools Tests - Community Hooks (opt-in)
*
* Tests for feat/hooks-opt-in-1473d:
* - Hook file existence and permissions
* - Installer hook registration in install.js
* - Hook execution with opt-in enabled and disabled
* - Negative security tests for hooks
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { runHook } = require('./helpers/process-seam.cjs');
const { HOOK_FANOUT_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const HOOKS_DIR = path.join(__dirname, '..', 'hooks');
const isWindows = process.platform === 'win32';
// This is a bash FAN-OUT: the hook itself runs under `bash`, and it shells
// out to `node` (see hookEnv below, which puts node on PATH for exactly that
// reason). 15000ms was sized for a single-probe class, not this one. Same
// class as the observed CI failures in tests/quick-branching.test.cjs (PR
// #3787 run 32668773524) and tests/worktree-safety.test.cjs (`next` run
// 32608945654) — see HOOK_FANOUT_TIMEOUT_MS in ./helpers/timeouts.cjs for the
// class rationale.
const HOOK_TIMEOUT_MS = HOOK_FANOUT_TIMEOUT_MS;
// Ensure the running node binary is on PATH so bash hooks can call `node`
// (Claude Code shell sessions do not have `node` on PATH).
const hookEnv = {
...process.env,
PATH: `${path.dirname(process.execPath)}:${process.env.PATH || '/usr/local/bin:/usr/bin:/bin'}`,
};
// Wrapper that always injects hookEnv so bash hooks can find `node`.
// Preserves the legacy spawnSync-shaped return (`status`, `stdout`, `stderr`,
// `signal`) that every call site in this file asserts against.
function spawnHook(hookPath, options) {
const r = runHook(hookPath, [], {
...options,
interpreter: 'bash',
env: hookEnv,
timeoutMs: HOOK_TIMEOUT_MS,
});
return { status: r.exitCode, stdout: r.stdout, stderr: r.stderr, signal: r.signal };
}
// ─── Helpers ────────────────────────────────────────────────────────────────
function createTempProject(prefix = 'gsd-hook-test-') {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true });
return tmpDir;
}
function cleanup(tmpDir) {
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- this IS the local teardown helper; wrapping helpers.cjs cleanup would create a circular dependency
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
}
function writeConfigWithHooks(tmpDir, enabled) {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({
model_profile: 'balanced',
hooks: { community: enabled }
}, null, 2)
);
}
function writeMinimalStateMd(tmpDir, content) {
const defaultContent = content || '# Session State\n\n**Current Phase:** 01\n**Status:** Active\n';
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
defaultContent
);
}
// ─────────────────────────────────────────────────────────────────────────────
// 1. Hook file existence and permissions
// ─────────────────────────────────────────────────────────────────────────────
describe('hook file validation', () => {
test('gsd-session-state.sh exists', () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-session-state.sh');
assert.ok(fs.existsSync(hookPath), 'gsd-session-state.sh should exist');
});
test('gsd-validate-commit.sh exists', () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
assert.ok(fs.existsSync(hookPath), 'gsd-validate-commit.sh should exist');
});
test('gsd-phase-boundary.sh exists', () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-phase-boundary.sh');
assert.ok(fs.existsSync(hookPath), 'gsd-phase-boundary.sh should exist');
});
test('gsd-session-state.sh is executable', { skip: isWindows ? 'Windows has no POSIX file permissions' : false }, () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-session-state.sh');
const stat = fs.statSync(hookPath);
assert.ok((stat.mode & 0o111) !== 0, 'gsd-session-state.sh should be executable');
});
test('gsd-validate-commit.sh is executable', { skip: isWindows ? 'Windows has no POSIX file permissions' : false }, () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
const stat = fs.statSync(hookPath);
assert.ok((stat.mode & 0o111) !== 0, 'gsd-validate-commit.sh should be executable');
});
test('gsd-phase-boundary.sh is executable', { skip: isWindows ? 'Windows has no POSIX file permissions' : false }, () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-phase-boundary.sh');
const stat = fs.statSync(hookPath);
assert.ok((stat.mode & 0o111) !== 0, 'gsd-phase-boundary.sh should be executable');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 2. Installer hook registration
// Migrated (#455): uses typed exports from bin/install.js instead of
// source-grep assertions (retiring pending-migration-to-typed-ir token).
// ─────────────────────────────────────────────────────────────────────────────
// Typed import — no source-grep needed (#455)
const { GSD_UNINSTALL_HOOKS } = require(
path.join(__dirname, '..', 'bin', 'install.js')
);
const { buildHookCommand } = require(
path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'runtime-hooks-surface.cjs')
);
describe('installer hook registration', () => {
test('GSD_UNINSTALL_HOOKS includes all 3 opt-in bash hooks', () => {
assert.ok(Array.isArray(GSD_UNINSTALL_HOOKS), 'GSD_UNINSTALL_HOOKS must be an array');
assert.ok(
GSD_UNINSTALL_HOOKS.includes('gsd-validate-commit.sh'),
'GSD_UNINSTALL_HOOKS must include gsd-validate-commit.sh'
);
assert.ok(
GSD_UNINSTALL_HOOKS.includes('gsd-session-state.sh'),
'GSD_UNINSTALL_HOOKS must include gsd-session-state.sh'
);
assert.ok(
GSD_UNINSTALL_HOOKS.includes('gsd-phase-boundary.sh'),
'GSD_UNINSTALL_HOOKS must include gsd-phase-boundary.sh'
);
});
test('GSD_UNINSTALL_HOOKS includes all core JS hooks', () => {
const requiredJsHooks = [
'gsd-statusline.js',
'gsd-check-update.js',
'gsd-context-monitor.js',
];
for (const hook of requiredJsHooks) {
assert.ok(
GSD_UNINSTALL_HOOKS.includes(hook),
`GSD_UNINSTALL_HOOKS must include ${hook}`
);
}
});
test('buildHookCommand generates a command string for gsd-validate-commit.sh', () => {
// buildHookCommand(configDir, hookName, opts) returns a non-null string command
// or null when the platform cannot run the hook. On non-Windows unix, .sh hooks
// always produce a command string.
const tmpConfigDir = os.tmpdir();
const cmd = buildHookCommand(tmpConfigDir, 'gsd-validate-commit.sh', { platform: 'linux' });
// On Linux, .sh hooks should always resolve to a non-null string
assert.ok(
cmd === null || (typeof cmd === 'string' && cmd.length > 0),
`buildHookCommand must return null or a non-empty string, got: ${JSON.stringify(cmd)}`
);
if (cmd !== null) {
assert.ok(
cmd.includes('gsd-validate-commit.sh'),
`buildHookCommand result must reference the hook filename, got: ${cmd}`
);
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 3. Opt-in gating behavior
// ─────────────────────────────────────────────────────────────────────────────
describe('opt-in gating behavior', { skip: isWindows ? 'bash hooks require unix shell' : false }, () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('validate-commit is a no-op when hooks.community is false', () => {
writeConfigWithHooks(tmpDir, false);
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
const input = JSON.stringify({
tool_input: { command: 'git commit -m "WIP save"' }
});
const result = spawnHook(hookPath, {
input,
encoding: 'utf-8',
cwd: tmpDir,
});
// Should exit 0 (no-op) even with a bad commit message
assert.strictEqual(result.status, 0, `Should be no-op when disabled, got ${result.status}`);
});
test('validate-commit is a no-op when config.json is absent', (t) => {
// No config.json at all
const bareDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-hook-bare-'));
t.after(() => { cleanup(bareDir); });
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
const input = JSON.stringify({
tool_input: { command: 'git commit -m "WIP save"' }
});
const result = spawnHook(hookPath, {
input,
encoding: 'utf-8',
cwd: bareDir,
});
assert.strictEqual(result.status, 0, `Should be no-op without config.json, got ${result.status}`);
});
test('session-state is a no-op when hooks.community is false', () => {
writeConfigWithHooks(tmpDir, false);
writeMinimalStateMd(tmpDir);
const hookPath = path.join(HOOKS_DIR, 'gsd-session-state.sh');
const result = spawnHook(hookPath, {
input: '',
encoding: 'utf-8',
cwd: tmpDir,
});
assert.strictEqual(result.status, 0, `Should exit 0: ${result.stderr}`);
// Migrated #2974: typed assertion that stdout is empty (no JSON envelope
// emitted when the hook is a no-op). The previous shape grepped for
// "Project State Reminder" prose; now the contract is "no output".
assert.equal(result.stdout.trim(), '',
`Should produce no output when disabled: ${JSON.stringify(result.stdout)}`);
});
test('phase-boundary is a no-op when hooks.community is false', () => {
writeConfigWithHooks(tmpDir, false);
const hookPath = path.join(HOOKS_DIR, 'gsd-phase-boundary.sh');
const input = JSON.stringify({
tool_input: { file_path: '.planning/STATE.md' }
});
const result = spawnHook(hookPath, {
input,
encoding: 'utf-8',
cwd: tmpDir,
});
assert.strictEqual(result.status, 0, `Should exit 0: ${result.stderr}`);
// Migrated #2974: typed empty-stdout assertion (#2974).
assert.equal(result.stdout.trim(), '',
`Should produce no output when disabled: ${JSON.stringify(result.stdout)}`);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 4. Hook execution when enabled
// ─────────────────────────────────────────────────────────────────────────────
describe('hook execution when enabled', { skip: isWindows ? 'bash hooks require unix shell' : false }, () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
writeConfigWithHooks(tmpDir, true);
});
afterEach(() => {
cleanup(tmpDir);
});
test('validate-commit allows valid conventional commit', () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
const input = JSON.stringify({
tool_input: { command: 'git commit -m "fix(core): add locking mechanism"' }
});
const result = spawnHook(hookPath, {
input,
encoding: 'utf-8',
cwd: tmpDir,
});
assert.strictEqual(result.status, 0, `Valid commit should exit 0, got ${result.status}. stderr: ${result.stderr}`);
});
test('validate-commit blocks non-conventional commit', () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
const input = JSON.stringify({
tool_input: { command: 'git commit -m "WIP save"' }
});
const result = spawnHook(hookPath, {
input,
encoding: 'utf-8',
cwd: tmpDir,
});
assert.strictEqual(result.status, 2, `Non-conventional commit should exit 2, got ${result.status}`);
// Migrated #2974: parse the hook's JSON envelope and assert on typed
// fields (decision, reason). Hook protocol returns
// { decision: 'block', reason: '...' } for blocked commits.
const parsed = JSON.parse(result.stdout);
assert.strictEqual(parsed.decision, 'block',
`expected typed decision: 'block', got: ${JSON.stringify(parsed)}`);
// Assert on the typed `code` field (stable enum value), not the
// human-readable `reason` string. CR feedback (#3016): substring
// matching on `reason` is still text matching — the hook now emits
// a typed code alongside the prose so tests pin behavior, not copy.
assert.strictEqual(parsed.code, 'CONVENTIONAL_COMMITS_VIOLATION',
`expected typed code: 'CONVENTIONAL_COMMITS_VIOLATION', got: ${JSON.stringify(parsed)}`);
});
test('validate-commit allows non-commit commands', () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
const input = JSON.stringify({
tool_input: { command: 'git push origin main' }
});
const result = spawnHook(hookPath, {
input,
encoding: 'utf-8',
cwd: tmpDir,
});
assert.strictEqual(result.status, 0, `Non-commit command should exit 0, got ${result.status}`);
});
test('session-state outputs state info when enabled', () => {
writeMinimalStateMd(tmpDir);
const hookPath = path.join(HOOKS_DIR, 'gsd-session-state.sh');
const result = spawnHook(hookPath, {
input: '',
encoding: 'utf-8',
cwd: tmpDir,
});
assert.strictEqual(result.status, 0, `Should exit 0: ${result.stderr}`);
// Migrated #2974: parse the SessionStart JSON envelope and assert on
// typed fields. The hook now emits
// { hookSpecificOutput: { hookEventName, additionalContext, state_present, config_mode } }.
const parsed = JSON.parse(result.stdout);
assert.strictEqual(parsed.hookSpecificOutput.hookEventName, 'SessionStart');
assert.strictEqual(parsed.hookSpecificOutput.state_present, true,
'state_present must reflect that STATE.md was written by writeMinimalStateMd');
});
test('session-state exits 0 without .planning/ (in enabled project)', (t) => {
// Create a dir with config but no STATE.md
const noStateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-hook-nostate-'));
t.after(() => { cleanup(noStateDir); });
fs.mkdirSync(path.join(noStateDir, '.planning'), { recursive: true });
writeConfigWithHooks(noStateDir, true);
const hookPath = path.join(HOOKS_DIR, 'gsd-session-state.sh');
const result = spawnHook(hookPath, {
input: '',
encoding: 'utf-8',
cwd: noStateDir,
});
assert.strictEqual(result.status, 0, `Should exit 0: ${result.stderr}`);
// Migrated #2974: typed assertion on state_present field instead of
// grepping additionalContext text for "No .planning/ found".
const parsed = JSON.parse(result.stdout);
assert.strictEqual(parsed.hookSpecificOutput.state_present, false,
'state_present must be false when STATE.md is absent');
});
test('phase-boundary detects .planning/ writes when enabled', () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-phase-boundary.sh');
const input = JSON.stringify({
tool_input: { file_path: '.planning/STATE.md' }
});
const result = spawnHook(hookPath, {
input,
encoding: 'utf-8',
cwd: tmpDir,
});
assert.strictEqual(result.status, 0, `Should exit 0: ${result.stderr}`);
// Migrated #2974: parse the PostToolUse JSON envelope. The hook emits
// { hookSpecificOutput: { hookEventName, additionalContext,
// planning_modified, file_path } } when a .planning/ write is detected.
const parsed = JSON.parse(result.stdout);
assert.strictEqual(parsed.hookSpecificOutput.hookEventName, 'PostToolUse');
assert.strictEqual(parsed.hookSpecificOutput.planning_modified, true);
assert.strictEqual(parsed.hookSpecificOutput.file_path, '.planning/STATE.md');
});
// #2304 — Kimi tool vocabulary engages the hook: Kimi CLI registers this
// hook with matcher 'WriteFile|StrReplaceFile' and its file tools name the
// path field `path`, not `file_path` (kimi-cli src/kimi_cli/tools/file/
// write.py + replace.py). Pre-fix, the hook read '' on Kimi payloads and
// .planning/ writes were silently undetected.
test('phase-boundary detects .planning/ writes from Kimi tool_input.path (#2304)', () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-phase-boundary.sh');
const input = JSON.stringify({
tool_name: 'kimi_cli.tools.file:WriteFile',
tool_input: { path: '.planning/STATE.md', content: 'x' }
});
const result = spawnHook(hookPath, {
input,
encoding: 'utf-8',
cwd: tmpDir,
});
assert.strictEqual(result.status, 0, `Should exit 0: ${result.stderr}`);
const parsed = JSON.parse(result.stdout);
assert.strictEqual(parsed.hookSpecificOutput.planning_modified, true,
'Kimi path field must be detected — pre-fix the hook read an empty path (#2304)');
assert.strictEqual(parsed.hookSpecificOutput.file_path, '.planning/STATE.md');
});
// #2752 — `path` is the AUTHORITATIVE field (kimi-cli executes on it; its file
// tools send `path` only). `file_path` is model-controlled on Kimi (kimi-cli never
// sends it). The old precedence (`file_path || path`) let a model-supplied decoy
// `file_path` suppress the reminder for a real write or fabricate one for a file
// never touched. Mirrors the #2595 JS-guard fix: `path` wins, `file_path` is the
// fallback (Claude emits `file_path` and no `path`, so the fallback must remain).
test('phase-boundary prefers Kimi tool_input.path when both fields are present (#2752)', () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-phase-boundary.sh');
// Suppression repro: a real .planning/ write WITH a decoy non-empty file_path.
const suppressionInput = JSON.stringify({
tool_name: 'kimi_cli.tools.file:StrReplaceFile',
tool_input: { path: '.planning/STATE.md', file_path: 'unrelated.txt', edit: { old: 'a', new: 'b' } }
});
const suppressionResult = spawnHook(hookPath, {
input: suppressionInput,
encoding: 'utf-8',
cwd: tmpDir,
});
assert.strictEqual(suppressionResult.status, 0, `Should exit 0: ${suppressionResult.stderr}`);
const suppressionParsed = JSON.parse(suppressionResult.stdout);
assert.strictEqual(suppressionParsed.hookSpecificOutput.planning_modified, true,
'A real .planning/STATE.md write must NOT be suppressed by a model-supplied decoy file_path (#2752)');
assert.strictEqual(suppressionParsed.hookSpecificOutput.file_path, '.planning/STATE.md',
'path must win over file_path — the runtime executes on path, file_path is the fallback');
// Fabrication repro: a write ELSEWHERE with a decoy file_path pointing into .planning/.
const fabricationInput = JSON.stringify({
tool_name: 'kimi_cli.tools.file:StrReplaceFile',
tool_input: { path: 'src/index.ts', file_path: '.planning/STATE.md', edit: { old: 'a', new: 'b' } }
});
const fabricationResult = spawnHook(hookPath, {
input: fabricationInput,
encoding: 'utf-8',
cwd: tmpDir,
});
assert.strictEqual(fabricationResult.status, 0, `Should exit 0: ${fabricationResult.stderr}`);
// No reminder emitted — the write was to src/index.ts; the decoy .planning/
// file_path must NOT fabricate a reminder for a file never touched.
assert.strictEqual(fabricationResult.stdout, '',
'A decoy .planning/ file_path must NOT fabricate a reminder when the real path is outside .planning/ (#2752)');
});
test('phase-boundary negative control: Kimi path outside .planning/ stays silent (#2304)', () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-phase-boundary.sh');
const input = JSON.stringify({
tool_name: 'kimi_cli.tools.file:StrReplaceFile',
tool_input: { path: 'src/index.ts', edit: { old: 'a', new: 'b' } }
});
const result = spawnHook(hookPath, {
input,
encoding: 'utf-8',
cwd: tmpDir,
});
assert.strictEqual(result.status, 0, `Should exit 0: ${result.stderr}`);
assert.equal(result.stdout.trim(), '',
'non-.planning/ Kimi writes must produce no output');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 5. Negative security tests for hooks
// ─────────────────────────────────────────────────────────────────────────────
describe('hook security tests', { skip: isWindows ? 'bash hooks require unix shell' : false }, () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
writeConfigWithHooks(tmpDir, true);
});
afterEach(() => {
cleanup(tmpDir);
});
test('validate-commit blocks message with shell metacharacters', () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
const input = JSON.stringify({
tool_input: { command: 'git commit -m "$(rm -rf /)"' }
});
const result = spawnHook(hookPath, {
input,
encoding: 'utf-8',
cwd: tmpDir,
});
assert.strictEqual(result.status, 2, `Shell metacharacter message should be blocked: ${result.status}`);
// Migrated #2974: typed JSON envelope assertion (parsed.decision === 'block').
assert.strictEqual(JSON.parse(result.stdout).decision, 'block');
});
test('validate-commit blocks message with backtick injection', () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
const input = JSON.stringify({
tool_input: { command: 'git commit -m "`whoami`"' }
});
const result = spawnHook(hookPath, {
input,
encoding: 'utf-8',
cwd: tmpDir,
});
assert.strictEqual(result.status, 2, `Backtick injection should be blocked: ${result.status}`);
// Migrated #2974: typed JSON envelope assertion (parsed.decision === 'block').
assert.strictEqual(JSON.parse(result.stdout).decision, 'block');
});
test('validate-commit allows commit with scope containing special chars', () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
const input = JSON.stringify({
tool_input: { command: 'git commit -m "fix(api/v2): handle edge case"' }
});
const result = spawnHook(hookPath, {
input,
encoding: 'utf-8',
cwd: tmpDir,
});
assert.strictEqual(result.status, 0, `Valid commit with / in scope should be allowed: ${result.status}`);
});
test('phase-boundary handles malformed JSON input gracefully', () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-phase-boundary.sh');
const input = 'not json at all';
const result = spawnHook(hookPath, {
input,
encoding: 'utf-8',
cwd: tmpDir,
});
assert.strictEqual(result.status, 0, `Should not crash on malformed JSON: ${result.stderr}`);
});
test('hooks handle config.json with broken JSON gracefully', () => {
// Write malformed JSON config
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
'{ broken json'
);
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
const input = JSON.stringify({
tool_input: { command: 'git commit -m "WIP save"' }
});
const result = spawnHook(hookPath, {
input,
encoding: 'utf-8',
cwd: tmpDir,
});
// Should exit 0 (treat malformed config as disabled)
assert.strictEqual(result.status, 0, `Malformed config should be treated as disabled: ${result.status}`);
});
});