Files
msd-core/tests/windsurf-hooks-bridge.test.cjs
Tom Boucher b647e28313 chore(#4727): name the tool-conversion helpers for the runtime that uses them (#4732)
* chore(#4727): name the tool-conversion helpers for the runtime that uses them

GSD has had no Gemini runtime since #1928 removed it (Google sunset Gemini CLI
on 2026-06-18, shipped 1.8.0), yet two helpers were still named for it:

  claudeToGeminiTools    -> claudeToAntigravityTools
  convertGeminiToolName  -> convertAntigravityToolName

The sole consumer is convertClaudeAgentToAntigravityAgent, whose own comment
read "Map tools to Gemini equivalents (reuse existing convertGeminiToolName)".
Nothing named Gemini consumes them, because nothing named Gemini exists. The
new names follow the convention the file already sets with its neighbouring
Copilot pair, claudeToCopilotTools / convertCopilotToolName.

Zero behavior change. Every mapped VALUE is byte-identical, deliberately:

  read_file, write_file, replace, run_shell_command, glob,
  search_file_content, google_web_search, web_fetch, write_todos

Those are Gemini's built-in tool dialect and Antigravity genuinely speaks it.
This rename covers only the identifiers, which are the one part of the surface
that was GSD's choice rather than Google's contract.

Renamed in BOTH copies. CLAUDE.md labels bin/install.js "(generated)", but no
script emits it -- build:lib is tsc -p tsconfig.build.json and writes only
gsd-core/bin/lib/**. These converters are the #1099/#1173/#1182 situation: they
were extracted into src/runtime-artifact-conversion.cts while bin/install.js
kept its own working inline copies, so each symbol existed twice in two
independently hand-maintained files. Renaming one would have left two names for
one concept. Verified first that no capability descriptor resolves either by
name -- antigravity's descriptor names only convertClaudeCommandToAntigravitySkill
and convertClaudeAgentToAntigravityAgent, neither of which moved.

Comments keep their reasoning and their issue refs (#3362 AskUserQuestion,
#1394 Skill/SlashCommand); only the subject is corrected, from "Gemini CLI" to
Antigravity speaking the Gemini dialect. Those describe the dialect's behavior,
which is still Antigravity's behavior, so deleting them would destroy the record
of two real bugs.

docs/research/gemini-to-antigravity-migration.md is left unedited and carries a
dated addendum instead: it is pinned to c0b2a05d2f and quotes #1928's commit
message verbatim, so rewriting a citation to match a later tree would falsify a
primary source. ADR-1593's dimension-3 row is updated, because that table is a
present-tense index of which helper implements each dimension and would
otherwise name a symbol that no longer exists.

Coverage: the module is the only export surface -- bin/install.js exports none
of these four, its inline copies being module-private -- so the new assertion
targets gsd-core/bin/lib/runtime-artifact-conversion.cjs and checks the new keys
present, the old keys absent (no alias left behind), deepStrictEqual on the whole
map so an added or removed key fails, and each excluded input individually. The
installer's inline copy stays covered behaviorally by the existing test that
imports convertClaudeAgentToAntigravityAgent from bin/install.js.

Phase 4a of epic #4709. Refs #4727

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#4727): fix three review blockers — ADR append-only, honest verdict, changeset

The isolated adversarial review returned BLOCK on three majors. All three were
right and all three are fixed here.

1. ADR-1593 was amended IN PLACE, violating docs/adr/README.md:5: "ADRs are
   append-only. Amendments extend existing ADRs with a dated section rather
   than replacing them." The dimension-3 row is restored to its original text
   and a dated "Amendment — 2026-09-14 (#4727)" section is appended instead.
   Worse than the policy breach: the previous commit applied OPPOSITE rules to
   two docs in one change, freezing the research doc's citations while
   rewriting the ADR's. Both now follow append-only.

2. The research-doc addendum claimed "the rename recommended in the PRESERVE
   table has landed" and that the "PRESERVE — trap" verdict "still holds".
   Neither is true. §2(c) is headed "MUST NOT be renamed" and the §6 table
   files both symbols under (c). The rename OVERTURNS that verdict, and the
   addendum now says so in those words: it is a NARROWING of (c), whose real
   subject is what Google owns -- the directories, the dialect, GEMINI.md, the
   model ids, and for these two symbols the mapped VALUES, which stay
   byte-identical. What (c) had swept in with them were two GSD-chosen
   identifiers no external contract references. Claiming endorsement from a
   verdict that forbade the change was the actual defect; the substantive
   argument was always sound.

   The addendum also now names the two superseded rows (~:55, ~:136) so a
   later reader is not misled, and discloses that §2(a)'s verbatim quotation
   (~:40) no longer matches its source: it reproduces the test docblock's old
   "convertGeminiToolName" wording, and #4727 reworded that docblock. The
   docblock had to move -- leaving it would make the #1928 guard describe a
   symbol that does not exist -- so the mismatch is disclosed rather than
   papered over by editing the quote, which is the one thing the addendum
   exists to avoid.

3. no-changelog was the wrong call. scripts/changeset/lint.cjs reports
   fail_missing_fragment because the diff touches src/ and bin/, and
   CONTRIBUTING.md:216 states src/ edits are user-facing "even though the
   generated .cjs is gitignored", with :224 adding "When unsure whether a
   change is user-facing, add the fragment." Confirmed concretely: gsd-core is
   in package.json's files array so the compiled module ships, and there is no
   exports map, so a consumer's deep require of
   gsd-core/bin/lib/runtime-artifact-conversion.cjs resolved
   .convertGeminiToolName before this change and gets undefined after. A
   Changed fragment is added. I had asserted "nothing user-facing" without
   running the repo's own changeset lint; the reviewer ran it.

Two review findings are accepted and recorded rather than fixed, both in the
research addendum's new §8: the bin/install.js half of the rename is
test-unprotected (that file exports none of the four identifiers and the export
audit asserts undefined for both spellings, so reverting it breaks no test --
its behavior is covered, its naming is not), and closing that needs the
repo-wide drift guard, which is #4729 and the last phase of the epic for
precisely this reason.

Refs #4727

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#4727): backfill changeset pr number to 4732

Refs #4727

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(#4727): assert both halves of the windsurf pre-write guard's contract

PR #4732's `conformance test (windows-latest, 24, shard 2/3)` failed on exactly
one assertion, and the base branch was green, so this is not inherited:

    tests/windsurf-hooks-bridge.test.cjs:104
    expected exit 2, got 0
    stderr: gsd-windsurf-pre-write: git probe 'git rev-parse --show-toplevel (cwd)' …
    duration_ms: 2909

Root cause, not a flake. hooks/gsd-windsurf-pre-write.js gives every git probe a
2000 ms budget (SPAWNOPT.timeout) and FAILS OPEN when a probe cannot run — its
own header states that outright, because "a hook bug must never wedge Cascade".
hooks/lib/git-probe.js (#3911) exists precisely because that budget is routinely
exceeded in CI; its header records a macOS run landing at 2084/2112/2177 ms,
just past the budget, and its reportIfUndetermined() announces the case on
stderr while deliberately changing no exit code.

So the hook has TWO documented outcomes:

  (a) probe resolved and the file's git root differs  -> BLOCK, exit 2
  (b) probe UNDETERMINED (timeout / spawn failure)   -> FAIL OPEN, exit 0,
                                                         announced on stderr

G1 and G1b asserted `status === 2` unconditionally, encoding only (a). They
therefore fail whenever the documented (b) occurs, which makes them
load-sensitive by construction. My diff did not touch that hook or its tests;
it re-packed the Windows chunks (6 -> 8 under #4737's derived cap), which raised
load enough to tip the 2 s budget and expose the latent assertion.

Both tests now branch on whether the hook ANNOUNCED an undetermined probe, and
assert the correct half in each case. This is not a loosened assertion:

  - undetermined -> exit 0 is REQUIRED. That arm still has teeth, because it
    fails if the hook ever blocks on a probe it could not determine, which is
    the dangerous direction — wedging the agent on a hook bug.
  - determined   -> the original exit 2 plus the original stderr-reason regex,
    unchanged.

The matcher is tied to reportIfUndetermined's exact message rather than a loose
/git probe/, and was proven against both strings: it matches the real
undetermined diagnostic and does NOT match a normal block message. No retry, no
sleep, no timing-dependent logic.

Deliberately NOT done: raising the hook's 2000 ms budget. That is forbidden here
without an explicit instruction, and it would only move the cliff rather than
fix the test's false premise.

Scope note: this is a test fix in a file unrelated to the rename, carried here
because it is what makes #4732 red. CLAUDE.md's no-deferral rule is explicit
that a defect found anywhere in the tree is fixed in the current change, and
that it overrides one-concern-per-PR.

Refs #4727

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 15:11:27 -04:00

403 lines
20 KiB
JavaScript

'use strict';
/**
* Windsurf/Cascade HOOK-BRIDGE upgrade — ADR-1239 / #2100 Stage 2.
*
* Stage 1 (folds) drove Windsurf's install onto the declarative capability
* descriptor (hostBehaviors) while leaving `hooksSurface: "none"`. Stage 2
* wires GSD's guard logic into Cascade's native hook bus: `.windsurf/hooks.json`
* (local) / `~/.codeium/windsurf/hooks.json` (global), with TWO blocking
* events — `pre_write_code` and `pre_run_command`. Cascade blocks via EXIT
* CODE 2 (+ a stderr reason), unlike Cursor's stdout-JSON `{ block, reason }`
* form — so this is a DIFFERENT protocol wired through the SAME infra shape
* as writeCursorHooksJson/removeCursorHooksJson (mirrors
* tests/cursor-hooks.test.cjs + tests/cursor-hook-bus-upgrade.test.cjs).
*
* Cascade has no context-injection channel (no `additional_context`-style
* advisory response), so the 4 advisory hooks GSD registers on Cursor
* (sessionStart, postToolUse, stop, subagentStart/subagentStop) have no
* Windsurf counterpart and are deliberately NOT ported.
*
* Test plan:
* Guard scripts (spawned directly, real process, real exit codes):
* G1 pre-write: file resolves to a different git root than cwd -> exit 2 + stderr
* G2 pre-write: file resolves to the SAME git root as cwd -> exit 0
* G3 pre-write: malformed JSON on stdin -> fail-open exit 0
* G4 pre-write: empty stdin -> fail-open exit 0
* G5 pre-command: a destructive command_line -> exit 2 + stderr
* G6 pre-command: a benign command_line -> exit 0
* G8 pre-command: prefixed/evasive rm -rf and force-push refspec forms -> exit 2
* G9 pre-command: force-push false-positive forms (comment/branch-name-contains) -> exit 0
* G10 pre-command: ReDoS-guard — oversized rm-shaped payload -> exit 0 in well under 1s
* G7 pre-command: malformed JSON on stdin -> fail-open exit 0
* Writer/reconcile (in-process, pure + one real install):
* W1 writeWindsurfHooksJson installs both scripts + both hooks.json entries
* W2 reconcileWindsurfHooksJson preserves user-owned entries
* W3 removeWindsurfHooksJson removes hooks.json when it becomes empty
* W4 removeWindsurfHooksJson preserves hooks.json when user entries remain
* W5 WINDSURF_HOOK_EVENTS / WINDSURF_EVENT_SCRIPT_MAP shape
* W6 hook scripts exist under hooks/
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { runHook: runHookSeam } = require('./helpers/process-seam.cjs');
const { gitOrThrow } = require('./helpers/git-fixture.cjs');
const { QUICK_SPAWN_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const { createTempDir, cleanup } = require('./helpers.cjs');
const {
WINDSURF_HOOK_EVENTS,
WINDSURF_EVENT_SCRIPT_MAP,
GSD_WINDSURF_HOOK_MARKER,
GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT,
GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT,
isManagedWindsurfHookEntry,
reconcileWindsurfHooksJson,
writeWindsurfHooksJson,
removeWindsurfHooksJson,
} = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs');
const HOOKS_DIR = path.join(__dirname, '..', 'hooks');
const PRE_WRITE_SCRIPT = path.join(HOOKS_DIR, GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT);
const PRE_COMMAND_SCRIPT = path.join(HOOKS_DIR, GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT);
function runHook(scriptPath, payload, opts = {}) {
const input = payload === undefined ? '' : (typeof payload === 'string' ? payload : JSON.stringify(payload));
const r = runHookSeam(scriptPath, [], {
input,
timeoutMs: QUICK_SPAWN_TIMEOUT_MS,
cwd: opts.cwd || os.tmpdir(),
});
return { status: r.exitCode, stdout: r.stdout, stderr: r.stderr, signal: r.signal };
}
function initGitRepo(dir) {
gitOrThrow(['init', '-q', '.'], { cwd: dir });
gitOrThrow(['config', 'user.email', 'gsd-test@example.com'], { cwd: dir });
gitOrThrow(['config', 'user.name', 'gsd-test'], { cwd: dir });
gitOrThrow(['commit', '--allow-empty', '-q', '-m', 'init'], { cwd: dir });
}
// ---------------------------------------------------------------------------
// Guard scripts — spawned directly, real exit codes
// ---------------------------------------------------------------------------
describe('gsd-windsurf-pre-write.js (pre_write_code guard)', () => {
test('G1: a write resolving to a DIFFERENT git root than cwd -> exit 2 + stderr reason', (t) => {
const cwdRepo = createTempDir('gsd-windsurf-write-cwd-');
const otherRepo = createTempDir('gsd-windsurf-write-other-');
t.after(() => { cleanup(cwdRepo); cleanup(otherRepo); });
initGitRepo(cwdRepo);
initGitRepo(otherRepo);
fs.writeFileSync(path.join(otherRepo, 'target.txt'), 'x');
const result = runHook(PRE_WRITE_SCRIPT, {
agent_action_name: 'pre_write_code',
tool_info: { file_path: path.join(otherRepo, 'target.txt') },
}, { cwd: cwdRepo });
// hooks/gsd-windsurf-pre-write.js gives every git probe a 2000 ms budget
// (SPAWNOPT.timeout) and, by documented design, fails OPEN (exit 0) via
// hooks/lib/git-probe.js's reportIfUndetermined() (#3911) when the probe
// cannot be resolved in time — its own header records a macOS CI run
// landing at 2084ms/2112ms/2177ms, just past the budget. This is NOT a
// tolerated flake: both halves of the hook's contract are asserted below,
// and which half applies is decided by the observed probe outcome, not
// guessed in advance.
const probeUndetermined = /git probe '[^']+'.*allowing this call because the probe's answer is unknown/.test(result.stderr);
if (probeUndetermined) {
assert.equal(result.status, 0, `probe was undetermined, so the hook must fail OPEN (exit 0), got ${result.status} (stderr: ${result.stderr})`);
} else {
assert.equal(result.status, 2, `expected exit 2, got ${result.status} (stderr: ${result.stderr})`);
assert.match(result.stderr, /differs from the active project root|inside a git internal/);
}
});
test('G1b: a write inside a DIFFERENT repo\'s .git internals -> exit 2 + stderr reason', (t) => {
const cwdRepo = createTempDir('gsd-windsurf-write-cwd-');
const otherRepo = createTempDir('gsd-windsurf-write-other-');
t.after(() => { cleanup(cwdRepo); cleanup(otherRepo); });
initGitRepo(cwdRepo);
initGitRepo(otherRepo);
const result = runHook(PRE_WRITE_SCRIPT, {
tool_info: { file_path: path.join(otherRepo, '.git', 'config') },
}, { cwd: cwdRepo });
// Same rationale as G1 above: hooks/gsd-windsurf-pre-write.js's 2000 ms
// per-probe budget, its documented fail-open, and hooks/lib/git-probe.js's
// reportIfUndetermined() (#3911) mean an undetermined probe is a
// legitimate, documented outcome (CI observed 2084ms/2112ms/2177ms —
// just past budget), not a flake to be tolerated by loosening the assert.
const probeUndetermined = /git probe '[^']+'.*allowing this call because the probe's answer is unknown/.test(result.stderr);
if (probeUndetermined) {
assert.equal(result.status, 0, `probe was undetermined, so the hook must fail OPEN (exit 0), got ${result.status} (stderr: ${result.stderr})`);
} else {
assert.equal(result.status, 2, `expected exit 2, got ${result.status} (stderr: ${result.stderr})`);
assert.match(result.stderr, /inside a git internal \(\.git\) directory/);
}
});
test('G2: a write resolving to the SAME git root as cwd -> exit 0 (allowed)', (t) => {
const repo = createTempDir('gsd-windsurf-write-same-');
t.after(() => cleanup(repo));
initGitRepo(repo);
fs.mkdirSync(path.join(repo, 'sub'));
const result = runHook(PRE_WRITE_SCRIPT, {
tool_info: { file_path: 'sub/new-file.txt' },
}, { cwd: repo });
assert.equal(result.status, 0, `expected exit 0, got ${result.status} (stderr: ${result.stderr})`);
});
test('G3: malformed JSON on stdin -> fail-open exit 0', () => {
const result = runHook(PRE_WRITE_SCRIPT, 'not-json-at-all{{{');
assert.equal(result.status, 0);
});
test('G4: empty stdin -> fail-open exit 0', () => {
const result = runHook(PRE_WRITE_SCRIPT, '');
assert.equal(result.status, 0);
});
test('missing tool_info.file_path -> fail-open exit 0', () => {
const result = runHook(PRE_WRITE_SCRIPT, { tool_info: {} });
assert.equal(result.status, 0);
});
});
describe('gsd-windsurf-pre-command.js (pre_run_command guard)', () => {
test('G5: a destructive command_line (rm -rf /) -> exit 2 + stderr reason', () => {
const result = runHook(PRE_COMMAND_SCRIPT, { tool_info: { command_line: 'rm -rf /' } });
assert.equal(result.status, 2, `expected exit 2, got ${result.status} (stderr: ${result.stderr})`);
assert.match(result.stderr, /rm -rf targeting the filesystem root/);
});
test('G5b: git push --force targeting a protected branch -> exit 2 + stderr reason', () => {
const result = runHook(PRE_COMMAND_SCRIPT, { tool_info: { command_line: 'git push --force origin main' } });
assert.equal(result.status, 2, `expected exit 2, got ${result.status} (stderr: ${result.stderr})`);
assert.match(result.stderr, /protected branch 'main'/);
});
test('G6: a benign command_line -> exit 0 (allowed)', () => {
const result = runHook(PRE_COMMAND_SCRIPT, { tool_info: { command_line: 'npm test' } });
assert.equal(result.status, 0, `expected exit 0, got ${result.status} (stderr: ${result.stderr})`);
});
test('G6b: rm -rf against an ordinary project path -> exit 0 (allowed, not a root wipe)', () => {
const result = runHook(PRE_COMMAND_SCRIPT, { tool_info: { command_line: 'rm -rf /tmp/gsd-scratch-dir' } });
assert.equal(result.status, 0, `expected exit 0, got ${result.status} (stderr: ${result.stderr})`);
});
test('G6c: git push --force to a feature branch -> exit 0 (allowed, not protected)', () => {
const result = runHook(PRE_COMMAND_SCRIPT, { tool_info: { command_line: 'git push --force origin feature/123' } });
assert.equal(result.status, 0, `expected exit 0, got ${result.status} (stderr: ${result.stderr})`);
});
test('G8: sudo-prefixed rm -rf / -> exit 2 (evasion via command prefix)', () => {
const result = runHook(PRE_COMMAND_SCRIPT, { tool_info: { command_line: 'sudo rm -rf /' } });
assert.equal(result.status, 2, `expected exit 2, got ${result.status} (stderr: ${result.stderr})`);
assert.match(result.stderr, /rm -rf targeting the filesystem root/);
});
test('G8b: absolute-path rm -rf / (/bin/rm) -> exit 2 (evasion via basename)', () => {
const result = runHook(PRE_COMMAND_SCRIPT, { tool_info: { command_line: '/bin/rm -rf /' } });
assert.equal(result.status, 2, `expected exit 2, got ${result.status} (stderr: ${result.stderr})`);
assert.match(result.stderr, /rm -rf targeting the filesystem root/);
});
test('G8c: git push -f origin HEAD:main -> exit 2 (refspec targeting protected branch)', () => {
const result = runHook(PRE_COMMAND_SCRIPT, { tool_info: { command_line: 'git push -f origin HEAD:main' } });
assert.equal(result.status, 2, `expected exit 2, got ${result.status} (stderr: ${result.stderr})`);
assert.match(result.stderr, /protected branch 'main'/);
});
test('G8d: git push origin +main -> exit 2 (+-prefixed force refspec)', () => {
const result = runHook(PRE_COMMAND_SCRIPT, { tool_info: { command_line: 'git push origin +main' } });
assert.equal(result.status, 2, `expected exit 2, got ${result.status} (stderr: ${result.stderr})`);
assert.match(result.stderr, /protected branch 'main'/);
});
test('G9: git push --force to a feature branch with a trailing comment mentioning main -> exit 0 (not a false positive)', () => {
const result = runHook(PRE_COMMAND_SCRIPT, {
tool_info: { command_line: 'git push --force origin feature/foo # deploy to main' },
});
assert.equal(result.status, 0, `expected exit 0, got ${result.status} (stderr: ${result.stderr})`);
});
test('G9b: git push --force to feature/main-fix -> exit 0 (branch name only CONTAINS "main", not a false positive)', () => {
const result = runHook(PRE_COMMAND_SCRIPT, { tool_info: { command_line: 'git push --force origin feature/main-fix' } });
assert.equal(result.status, 0, `expected exit 0, got ${result.status} (stderr: ${result.stderr})`);
});
test('G9c: env-prefixed benign command -> exit 0 (env prefix alone is not destructive)', () => {
const result = runHook(PRE_COMMAND_SCRIPT, { tool_info: { command_line: 'env FOO=1 npm test' } });
assert.equal(result.status, 0, `expected exit 0, got ${result.status} (stderr: ${result.stderr})`);
});
test('G10: ReDoS-guard — a 200000+-char rm -rf-shaped payload is handled without catastrophic backtracking (length cap)', () => {
const payload = `rm -${'r'.repeat(200000)}!`;
// The ReDoS guard is `runHook`'s seam `timeoutMs: 10000`: catastrophic
// backtracking on this payload would run for minutes, so the hook is
// SIGKILL'd and returns a non-zero status — the exit-0 assertion below IS
// the ReDoS check. (The prior `elapsedMs < 1000` wall-clock assertion was
// redundant with that and flaked on a loaded bench at ~1.1s — CLAUDE.md:
// do not assert on wall-clock time.)
const result = runHook(PRE_COMMAND_SCRIPT, { tool_info: { command_line: payload } });
assert.equal(result.status, 0, `expected exit 0 (length-capped allow, no ReDoS timeout), got ${result.status} (stderr: ${result.stderr})`);
});
test('G7: malformed JSON on stdin -> fail-open exit 0', () => {
const result = runHook(PRE_COMMAND_SCRIPT, 'not-json-at-all{{{');
assert.equal(result.status, 0);
});
test('empty stdin -> fail-open exit 0', () => {
const result = runHook(PRE_COMMAND_SCRIPT, '');
assert.equal(result.status, 0);
});
test('missing tool_info.command_line -> fail-open exit 0', () => {
const result = runHook(PRE_COMMAND_SCRIPT, { tool_info: {} });
assert.equal(result.status, 0);
});
});
// ---------------------------------------------------------------------------
// W5/W6: constants + on-disk scripts
// ---------------------------------------------------------------------------
test('W5: WINDSURF_HOOK_EVENTS is exactly the 2 wired Cascade events', () => {
assert.deepEqual([...WINDSURF_HOOK_EVENTS].sort(), ['pre_run_command', 'pre_write_code']);
});
test('W5b: WINDSURF_EVENT_SCRIPT_MAP maps every event to a .js script', () => {
for (const ev of WINDSURF_HOOK_EVENTS) {
const script = WINDSURF_EVENT_SCRIPT_MAP[ev];
assert.ok(typeof script === 'string' && script.endsWith('.js'), `${ev} must map to a .js script, got: ${script}`);
}
});
test('W6: both hook scripts exist under hooks/', () => {
assert.ok(fs.existsSync(PRE_WRITE_SCRIPT), 'hooks/gsd-windsurf-pre-write.js must exist');
assert.ok(fs.existsSync(PRE_COMMAND_SCRIPT), 'hooks/gsd-windsurf-pre-command.js must exist');
});
// ---------------------------------------------------------------------------
// W1: writeWindsurfHooksJson — direct writer call
// ---------------------------------------------------------------------------
describe('writeWindsurfHooksJson', () => {
test('W1: installs both scripts and both hooks.json entries with the marker', (t) => {
const targetDir = createTempDir('gsd-windsurf-writer-');
t.after(() => cleanup(targetDir));
const src = path.join(__dirname, '..');
const result = writeWindsurfHooksJson(targetDir, src, { platform: process.platform });
assert.equal(result.hooksJsonPath, path.join(targetDir, 'hooks.json'));
assert.ok(result.changed, 'first write must report changed=true');
assert.ok(fs.existsSync(path.join(targetDir, 'hooks', GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT)), 'pre-write script must be installed');
assert.ok(fs.existsSync(path.join(targetDir, 'hooks', GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT)), 'pre-command script must be installed');
const written = JSON.parse(fs.readFileSync(result.hooksJsonPath, 'utf8'));
assert.ok(written.hooks && typeof written.hooks === 'object', 'hooks.json must have a nested hooks table');
for (const ev of WINDSURF_HOOK_EVENTS) {
assert.ok(Array.isArray(written.hooks[ev]), `hooks.json must have a ${ev} array`);
assert.equal(written.hooks[ev].length, 1, `${ev} must have exactly 1 managed entry`);
assert.equal(written.hooks[ev][0][GSD_WINDSURF_HOOK_MARKER], true, `${ev} entry must carry the GSD managed marker`);
assert.equal(typeof written.hooks[ev][0].command, 'string', `${ev} entry command must be a bare string (Cascade shape, not Cursor's {type,command})`);
assert.equal(written.hooks[ev][0].type, undefined, `${ev} entry must NOT have Cursor's 'type' field`);
}
});
test('W1b: is idempotent (second write reports changed=false)', (t) => {
const targetDir = createTempDir('gsd-windsurf-writer-idem-');
t.after(() => cleanup(targetDir));
const src = path.join(__dirname, '..');
writeWindsurfHooksJson(targetDir, src, { platform: process.platform });
const second = writeWindsurfHooksJson(targetDir, src, { platform: process.platform });
assert.equal(second.changed, false, 're-running the writer with no changes must report changed=false');
});
});
// ---------------------------------------------------------------------------
// W2/W3/W4: reconcileWindsurfHooksJson / removeWindsurfHooksJson
// ---------------------------------------------------------------------------
describe('reconcileWindsurfHooksJson / removeWindsurfHooksJson', () => {
function managedEntry(command) {
return { command, [GSD_WINDSURF_HOOK_MARKER]: true };
}
function userEntry(command) {
return { command };
}
test('W2: preserves user-owned entries across both events', (t) => {
const dir = createTempDir('gsd-windsurf-reconcile-');
t.after(() => cleanup(dir));
const hooksJsonPath = path.join(dir, 'hooks.json');
fs.writeFileSync(hooksJsonPath, JSON.stringify({
hooks: {
pre_write_code: [userEntry('echo user-write-hook')],
pre_run_command: [userEntry('echo user-command-hook')],
},
}, null, 2) + '\n');
const managedEntries = {
pre_write_code: managedEntry('node /gsd/pre-write.js'),
pre_run_command: managedEntry('node /gsd/pre-command.js'),
};
reconcileWindsurfHooksJson(hooksJsonPath, managedEntries);
const written = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8'));
assert.equal(written.hooks.pre_write_code.length, 2, 'pre_write_code: 1 user + 1 managed');
assert.equal(written.hooks.pre_run_command.length, 2, 'pre_run_command: 1 user + 1 managed');
assert.ok(written.hooks.pre_write_code.some((e) => e.command === 'echo user-write-hook'));
assert.ok(written.hooks.pre_write_code.some((e) => isManagedWindsurfHookEntry(e)));
});
test('W3: removeWindsurfHooksJson removes hooks.json when it becomes empty', (t) => {
const dir = createTempDir('gsd-windsurf-remove-empty-');
t.after(() => cleanup(dir));
const hooksJsonPath = path.join(dir, 'hooks.json');
reconcileWindsurfHooksJson(hooksJsonPath, {
pre_write_code: managedEntry('node /gsd/pre-write.js'),
pre_run_command: managedEntry('node /gsd/pre-command.js'),
});
assert.ok(fs.existsSync(hooksJsonPath));
const result = removeWindsurfHooksJson(dir);
assert.equal(result.changed, true);
assert.equal(fs.existsSync(hooksJsonPath), false, 'empty hooks.json must be removed');
});
test('W4: removeWindsurfHooksJson preserves hooks.json when user entries remain', (t) => {
const dir = createTempDir('gsd-windsurf-remove-preserve-');
t.after(() => cleanup(dir));
const hooksJsonPath = path.join(dir, 'hooks.json');
fs.writeFileSync(hooksJsonPath, JSON.stringify({
hooks: {
pre_write_code: [
managedEntry('node /gsd/pre-write.js'),
userEntry('echo user-write-hook'),
],
},
}, null, 2) + '\n');
removeWindsurfHooksJson(dir);
assert.ok(fs.existsSync(hooksJsonPath), 'hooks.json must remain (user entries present)');
const written = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8'));
assert.equal(written.hooks.pre_write_code.length, 1);
assert.equal(written.hooks.pre_write_code[0].command, 'echo user-write-hook');
});
});