feat(#2100): drive Windsurf through the EoS descriptor + wire Cascade's blocking hook bus (ADR-1239)
Fold all 10 residual isWindsurf branches in bin/install.js onto descriptor-driven hostBehaviors (byte-parity — no fold changes any install output): - 2 dead destructures dropped (uninstall, finishInstall); the dead `else if (isWindsurf)` legacy agent-loop arm removed (windsurf ∈ _DESCRIPTOR_AGENTS_RUNTIMES → unreachable). - skipSharedHooksInstall:true folds the two `!isWindsurf` shared-hooks exclusions. - legacyDevinSkillsCleanup:true folds the `.devin`→`.windsurf` one-time cleanup gate. - installsCommandBodiesForWorkflowDelegation:true folds the #1629 command-body copy (workflow-delegation target — load-bearing; local-install verified intact). - verificationStyle:"windsurf-workflows" folds the workflow-count report. - Corrected stale _LEGACY_SCAN_SUBDIR_NAMES + hooks-json manifest comments (cursor + windsurf). Zero live runtime==='windsurf'/isWindsurf branches remain across bin/install.js, install-engine.cts, surface.cts, runtime-artifact-conversion.cts (AC2 guard scans all four). UPGRADE (Cascade hook bus): wire GSD's write/command safety guards into Windsurf's native hook bus. New hooksSurface 'windsurf-hooks-json' (VALID_HOOKS_SURFACES 7→8, GATE A profile-marker-only allowlist, the HooksSurface union) + writeWindsurfHooksJson (Cursor-templated, Cascade's flat {hooks:{<event>:[{command}]}} shape) writing .windsurf/hooks.json with two BLOCKING pre-hooks: - pre_write_code → gsd-windsurf-pre-write.js: blocks writes to a file outside the active git worktree / into .git internals. - pre_run_command → gsd-windsurf-pre-command.js: conservative destructive-command deny-list (rm -rf of root/home incl. sudo/env/path-prefixed forms; fork bombs; force-push refspec forms — HEAD:main, +main, --force/-f — to main/master/next). Both use Cascade's protocol (stdin JSON, exit 2 + stderr to block, exit 0 to allow, fail-open on error/timeout). Tokenize-based classifier (no catastrophic-backtracking regex; 4096-char cap) with the fail-closed false-positives fixed post-review. The 4 advisory GSD guards + pre_mcp_tool_use + 5 post_* logging events are deliberately NOT wired: Cascade has no context-injection channel for advisory hooks and GSD has no MCP guard — porting them would be non-functional padding (documented; codebuddy #2098 / copilot #2099 faithful-subset precedent). extendedHookEvents stays []. Golden: the 2 guard scripts ship in the shared hook bundle (HOOKS_TO_COPY + the shared managed-hooks-registry), exactly like cursor's 6 gsd-cursor-*.js scripts — so the 8 shared-bundle runtimes' fixtures gain the 2 inert windsurf scripts + the registry hash (functionally inert for non-windsurf; the established cursor pattern). No install-output change beyond that (the folds are byte-parity; skip-bundle runtimes untouched). New scripts registered in managed-hooks-registry + build-hooks + INVENTORY. Tests: declarative-reference- windsurf (adapter/axes/fail-closed + AC2 guard) + windsurf-hooks-bridge (live exit-2 blocking + allow/fail-open + ReDoS-bound + writer/reconcile/remove idempotency); VALID_HOOKS_SURFACES pin updated to 8. Matrix hookBus delta + changeset (Changed). capability-registry regenerated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -3967,12 +3967,12 @@ describe('ADR-1016 phase 5a: closed-vocab set exports', () => {
|
||||
assert.strictEqual(VALID_COMMAND_STYLES.size, 2);
|
||||
});
|
||||
|
||||
test('VALID_HOOKS_SURFACES has exactly 7 values', () => {
|
||||
test('VALID_HOOKS_SURFACES has exactly 8 values', () => {
|
||||
assert.ok(VALID_HOOKS_SURFACES instanceof Set);
|
||||
for (const v of ['settings-json', 'codex-hooks-json', 'cursor-hooks-json', 'copilot-inline', 'cline-rules', 'kimi-hooks-toml', 'none']) {
|
||||
for (const v of ['settings-json', 'codex-hooks-json', 'cursor-hooks-json', 'copilot-inline', 'cline-rules', 'kimi-hooks-toml', 'windsurf-hooks-json', 'none']) {
|
||||
assert.ok(VALID_HOOKS_SURFACES.has(v), 'VALID_HOOKS_SURFACES must contain "' + v + '"');
|
||||
}
|
||||
assert.strictEqual(VALID_HOOKS_SURFACES.size, 7);
|
||||
assert.strictEqual(VALID_HOOKS_SURFACES.size, 8);
|
||||
});
|
||||
|
||||
test('VALID_HOOK_EVENTS has exactly 2 managed-hook dialects (claude/gemini)', () => {
|
||||
|
||||
180
tests/declarative-reference-windsurf.test.cjs
Normal file
180
tests/declarative-reference-windsurf.test.cjs
Normal file
@@ -0,0 +1,180 @@
|
||||
// allow-test-rule: structural-regression-guard — AC2: assert no `runtime === 'windsurf'` string-equality branch, no live `isWindsurf` read remains in bin/install.js, src/install-engine.cts, src/surface.cts, or src/runtime-artifact-conversion.cts — a source-text property, so source-grep is the faithful check (#2100)
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Declarative reference host — Windsurf (#2100 / ADR-1239 EoS).
|
||||
*
|
||||
* STAGE 1 (folds): Windsurf already installs through the descriptor-driven
|
||||
* artifactLayout (agents/commands, each with a named `converter`), and its
|
||||
* capability.json already declares `hostIntegration` + `hostBehaviors` axes
|
||||
* (skipSharedHooksInstall, legacyDevinSkillsCleanup,
|
||||
* installsCommandBodiesForWorkflowDelegation, verificationStyle). Every
|
||||
* former `isWindsurf` branch in bin/install.js was folded onto those
|
||||
* descriptor axes (see the `#2100: isWindsurf dropped` comments left at the
|
||||
* fold sites).
|
||||
*
|
||||
* STAGE 2 (this file's focus, HOOK-BRIDGE): Windsurf's `hooksSurface` moved
|
||||
* from `"none"` to `"windsurf-hooks-json"` — GSD's write/command safety
|
||||
* guards are now wired into Cascade's native `.windsurf/hooks.json` /
|
||||
* `~/.codeium/windsurf/hooks.json` hook bus via `writeWindsurfHooksJson`
|
||||
* (mirrors `writeCursorHooksJson`'s infra shape, but Cascade blocks via EXIT
|
||||
* CODE 2 + stderr, not Cursor's stdout-JSON `{block,reason}` form). Only 2 of
|
||||
* Cascade's documented hook events (pre_write_code, pre_run_command) have a
|
||||
* blocking counterpart wired — Cascade has no context-injection channel, so
|
||||
* the 4 advisory events GSD registers on Cursor have no Windsurf analog.
|
||||
* Hook-bus mechanics (writer/reconcile/remove + the 2 guard scripts spawned
|
||||
* directly) are covered in tests/windsurf-hooks-bridge.test.cjs — not
|
||||
* duplicated here.
|
||||
*
|
||||
* This test is the reference-host dogfood mirroring
|
||||
* tests/declarative-reference-copilot.test.cjs: it (1) classifies Windsurf's
|
||||
* profile via profileOf, (2) confirms the public declarative adapter
|
||||
* classifies it as declarative, (3) round-trips a real install proving a
|
||||
* gsd agent surface is emitted, (4) proves negotiation fails CLOSED on a
|
||||
* corrupted descriptor, (5) proves the validator accepts the descriptor,
|
||||
* (6) asserts the new hooksSurface value + GATE A membership, and (7)
|
||||
* source-greps the folded modules for the retired `isWindsurf` branches (AC2).
|
||||
*/
|
||||
|
||||
const { test, before } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const {
|
||||
profileOf,
|
||||
negotiateHostCapabilities,
|
||||
PROFILE_BASELINES,
|
||||
UNDOCUMENTED,
|
||||
} = require('../gsd-core/bin/lib/host-integration.cjs');
|
||||
const { validateCapability } = require('../gsd-core/bin/lib/capability-validator.cjs');
|
||||
const { createDeclarativeAdapter } = require('../gsd-core/bin/lib/adapter-declarative.cjs');
|
||||
const {
|
||||
resolveRuntimeConfigIntent,
|
||||
resolveInstallPlan,
|
||||
} = require('../gsd-core/bin/lib/runtime-config-adapter-registry.cjs');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
const { walk, runMinimalInstall, BUILD_SCRIPT } = require('./helpers/install-shared.cjs');
|
||||
|
||||
const DESC = path.join(__dirname, '..', 'capabilities', 'windsurf', 'capability.json');
|
||||
const WINDSURF_CAP = JSON.parse(fs.readFileSync(DESC, 'utf8'));
|
||||
const WINDSURF_AXES = WINDSURF_CAP.runtime.hostIntegration;
|
||||
|
||||
// hooks/dist is gitignored and built (mirrors golden-install-parity harness).
|
||||
before(() => {
|
||||
execFileSync(process.execPath, [BUILD_SCRIPT], { encoding: 'utf-8', stdio: 'pipe' });
|
||||
});
|
||||
|
||||
test('Windsurf classifies as the declarative-cli reference profile (profileOf)', () => {
|
||||
const desc = JSON.parse(fs.readFileSync(DESC, 'utf8'));
|
||||
const axes = desc.runtime.hostIntegration;
|
||||
assert.ok(axes && axes.embeddingMode, 'windsurf descriptor declares hostIntegration axes');
|
||||
assert.equal(profileOf(axes), 'declarative-cli', 'Windsurf is a Declarative-CLI host');
|
||||
});
|
||||
|
||||
test('the public declarative adapter classifies Windsurf as a declarative host', () => {
|
||||
const adapter = createDeclarativeAdapter({ runtime: 'windsurf' });
|
||||
assert.equal(adapter.kind, 'declarative');
|
||||
assert.equal(adapter.runtime, 'windsurf');
|
||||
assert.equal(typeof adapter.install, 'function');
|
||||
assert.equal(typeof adapter.uninstall, 'function');
|
||||
});
|
||||
|
||||
test('a real Windsurf install emits a gsd agent surface (invocable)', () => {
|
||||
const { configDir, root } = runMinimalInstall({ runtime: 'windsurf', scope: 'global' });
|
||||
try {
|
||||
const files = walk(configDir);
|
||||
assert.ok(files.length > 0, 'install must emit artifacts');
|
||||
const gsdSurface = files.filter((f) => /gsd/i.test(path.relative(configDir, f)));
|
||||
assert.ok(gsdSurface.length > 0, 'install must emit a gsd agent surface (declarative reference)');
|
||||
const agentsDir = path.join(configDir, 'agents');
|
||||
assert.ok(fs.existsSync(agentsDir), 'agents/ directory must exist');
|
||||
const agentFiles = fs.readdirSync(agentsDir).filter((f) => f.startsWith('gsd-') && f.endsWith('.md'));
|
||||
assert.ok(agentFiles.length > 0, 'agents/ must contain gsd-*.md files');
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #2100 EoS/windsurf — fail-closed negotiation + validator acceptance +
|
||||
// the folded descriptor (mirrors codebuddy/antigravity/qwen/copilot reference tests).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('negotiateHostCapabilities never throws for windsurf, even fully corrupted', () => {
|
||||
assert.doesNotThrow(() => negotiateHostCapabilities({}));
|
||||
assert.doesNotThrow(() => negotiateHostCapabilities({ ...WINDSURF_AXES, embeddingMode: UNDOCUMENTED }));
|
||||
assert.doesNotThrow(() => negotiateHostCapabilities({ ...WINDSURF_AXES, embeddingMode: 'future-unknown' }));
|
||||
assert.doesNotThrow(() => negotiateHostCapabilities({ ...WINDSURF_AXES, dispatch: 'corrupted-not-an-object' }));
|
||||
assert.doesNotThrow(() => negotiateHostCapabilities({ ...WINDSURF_AXES, dispatch: { ...WINDSURF_AXES.dispatch, maxDepth: 'not-a-number' } }));
|
||||
});
|
||||
|
||||
test('a partial/empty windsurf descriptor degrades to the safe floor, not the declarative-cli baseline', () => {
|
||||
const result = negotiateHostCapabilities({});
|
||||
assert.equal(result.effective.embeddingMode, 'declarative', 'omitted embeddingMode degrades closed');
|
||||
assert.equal(result.effective.hookBus, 'none');
|
||||
assert.notDeepEqual(result.effective, PROFILE_BASELINES['declarative-cli']);
|
||||
assert.ok(result.warnings.length > 0);
|
||||
});
|
||||
|
||||
test('capabilities/windsurf/capability.json validates — no errors', () => {
|
||||
const errors = validateCapability(WINDSURF_CAP, 'windsurf');
|
||||
assert.deepEqual(errors, [], `validateCapability must return no errors, got: ${JSON.stringify(errors)}`);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #2100 Stage 2 — hooksSurface moved from 'none' to 'windsurf-hooks-json'
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('windsurf descriptor declares hooksSurface: windsurf-hooks-json', () => {
|
||||
assert.equal(WINDSURF_CAP.runtime.hooksSurface, 'windsurf-hooks-json');
|
||||
});
|
||||
|
||||
test('windsurf installSurface stays profile-marker-only (unchanged by Stage 2)', () => {
|
||||
const intent = resolveRuntimeConfigIntent('windsurf');
|
||||
assert.equal(intent.installSurface, 'profile-marker-only');
|
||||
assert.equal(intent.writesSharedSettings, false);
|
||||
assert.equal(intent.finishPermissionWriter, null);
|
||||
});
|
||||
|
||||
test('resolveInstallPlan(windsurf).hooksSurface is windsurf-hooks-json', () => {
|
||||
const plan = resolveInstallPlan('windsurf');
|
||||
assert.equal(plan.hooksSurface, 'windsurf-hooks-json');
|
||||
assert.equal(plan.installSurface, 'profile-marker-only');
|
||||
});
|
||||
|
||||
// -- AC2: the hardcoded branches are retired across all folded modules ------
|
||||
|
||||
test('no `runtime === "windsurf"` string-equality branch (nor live `isWindsurf` read) remains in bin/install.js, src/install-engine.cts, src/surface.cts, or src/runtime-artifact-conversion.cts (AC2)', () => {
|
||||
const strip = (src) => src
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/\/\/[^\r\n]*/g, '')
|
||||
.replace(/`[^`]*`/g, '');
|
||||
const repoRoot = path.join(__dirname, '..');
|
||||
const files = [
|
||||
path.join(repoRoot, 'bin', 'install.js'),
|
||||
path.join(repoRoot, 'src', 'install-engine.cts'),
|
||||
path.join(repoRoot, 'src', 'surface.cts'),
|
||||
path.join(repoRoot, 'src', 'runtime-artifact-conversion.cts'),
|
||||
];
|
||||
for (const file of files) {
|
||||
const src = fs.readFileSync(file, 'utf8');
|
||||
const stripped = strip(src);
|
||||
|
||||
const eqOffenders = stripped.match(/runtime\s*[!=]==\s*["']windsurf["']/g) || [];
|
||||
assert.deepEqual(eqOffenders, [],
|
||||
`AC2: no hardcoded runtime==='windsurf' branch may remain in ${path.relative(repoRoot, file)}; found: ${eqOffenders.join(', ')}`);
|
||||
|
||||
// Excludes legit enumeration sites: --windsurf CLI flag parsing, numbered-menu
|
||||
// maps, allRuntimes/_DESCRIPTOR_AGENTS_RUNTIMES set literals, config-dir
|
||||
// lists, the windsurf conversion FUNCTION names (convertClaudeAgentToWindsurfAgent
|
||||
// / convertClaudeCommandToWindsurfWorkflow), and hooksSurface==='windsurf-hooks-json'
|
||||
// / installSurface==='profile-marker-only' (descriptor-field strings, not
|
||||
// runtime literals) — none of those contain the token `isWindsurf`, so a
|
||||
// literal-word match is precise here.
|
||||
const isWindsurfHits = stripped.match(/\bisWindsurf\b/g) || [];
|
||||
assert.deepEqual(isWindsurfHits, [],
|
||||
`AC2: no live isWindsurf read may remain in ${path.relative(repoRoot, file)}; found ${isWindsurfHits.length} occurrence(s)`);
|
||||
}
|
||||
});
|
||||
@@ -331,11 +331,13 @@
|
||||
"hooks/gsd-statusline.js": "8ae31be7a006204b",
|
||||
"hooks/gsd-update-banner.js": "55143a25f978f301",
|
||||
"hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38",
|
||||
"hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd",
|
||||
"hooks/gsd-windsurf-pre-write.js": "92d4dbfbc36ab0cf",
|
||||
"hooks/gsd-workflow-guard.js": "91ae24a15d2bca6f",
|
||||
"hooks/gsd-worktree-path-guard.js": "838498aa91619740",
|
||||
"hooks/lib/git-cmd.js": "268ba15992ca0b23",
|
||||
"hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9",
|
||||
"hooks/managed-hooks-registry.cjs": "721d696556b7509f",
|
||||
"hooks/managed-hooks-registry.cjs": "82a4121cbcb82756",
|
||||
"mcp_config.json": "9956d6a6e88a49e1",
|
||||
"package.json": "dbf8353f77358bc1",
|
||||
"scripts/changeset/README.md": "86ff89331dfd94b2",
|
||||
|
||||
@@ -402,11 +402,13 @@
|
||||
"hooks/gsd-statusline.js": "3be32d2012c77fc1",
|
||||
"hooks/gsd-update-banner.js": "55143a25f978f301",
|
||||
"hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38",
|
||||
"hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd",
|
||||
"hooks/gsd-windsurf-pre-write.js": "92d4dbfbc36ab0cf",
|
||||
"hooks/gsd-workflow-guard.js": "91ae24a15d2bca6f",
|
||||
"hooks/gsd-worktree-path-guard.js": "65b934c3a1709e89",
|
||||
"hooks/lib/git-cmd.js": "268ba15992ca0b23",
|
||||
"hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9",
|
||||
"hooks/managed-hooks-registry.cjs": "e61da0f7a3037c35",
|
||||
"hooks/managed-hooks-registry.cjs": "29a8d4fa81378d7d",
|
||||
"package.json": "dbf8353f77358bc1",
|
||||
"scripts/changeset/README.md": "86ff89331dfd94b2",
|
||||
"scripts/changeset/cli.cjs": "68f92a344b199271",
|
||||
|
||||
@@ -401,11 +401,13 @@
|
||||
"hooks/gsd-statusline.js": "7c315416ffc99a9a",
|
||||
"hooks/gsd-update-banner.js": "b457746cb76c1957",
|
||||
"hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38",
|
||||
"hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd",
|
||||
"hooks/gsd-windsurf-pre-write.js": "92d4dbfbc36ab0cf",
|
||||
"hooks/gsd-workflow-guard.js": "59b46a74d19d58d3",
|
||||
"hooks/gsd-worktree-path-guard.js": "02be1bb504b22eb5",
|
||||
"hooks/lib/git-cmd.js": "268ba15992ca0b23",
|
||||
"hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9",
|
||||
"hooks/managed-hooks-registry.cjs": "f2e325aa9ba31647",
|
||||
"hooks/managed-hooks-registry.cjs": "1d955ec5d64e8a5f",
|
||||
"package.json": "dbf8353f77358bc1",
|
||||
"scripts/changeset/README.md": "86ff89331dfd94b2",
|
||||
"scripts/changeset/cli.cjs": "68f92a344b199271",
|
||||
|
||||
@@ -330,11 +330,13 @@
|
||||
"hooks/gsd-statusline.js": "7c315416ffc99a9a",
|
||||
"hooks/gsd-update-banner.js": "b457746cb76c1957",
|
||||
"hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38",
|
||||
"hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd",
|
||||
"hooks/gsd-windsurf-pre-write.js": "92d4dbfbc36ab0cf",
|
||||
"hooks/gsd-workflow-guard.js": "59b46a74d19d58d3",
|
||||
"hooks/gsd-worktree-path-guard.js": "02be1bb504b22eb5",
|
||||
"hooks/lib/git-cmd.js": "268ba15992ca0b23",
|
||||
"hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9",
|
||||
"hooks/managed-hooks-registry.cjs": "f2e325aa9ba31647",
|
||||
"hooks/managed-hooks-registry.cjs": "1d955ec5d64e8a5f",
|
||||
"package.json": "dbf8353f77358bc1",
|
||||
"scripts/changeset/README.md": "86ff89331dfd94b2",
|
||||
"scripts/changeset/cli.cjs": "68f92a344b199271",
|
||||
|
||||
@@ -402,11 +402,13 @@
|
||||
"hooks/gsd-statusline.js": "ef8dcb6d64fd4493",
|
||||
"hooks/gsd-update-banner.js": "55143a25f978f301",
|
||||
"hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38",
|
||||
"hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd",
|
||||
"hooks/gsd-windsurf-pre-write.js": "92d4dbfbc36ab0cf",
|
||||
"hooks/gsd-workflow-guard.js": "91ae24a15d2bca6f",
|
||||
"hooks/gsd-worktree-path-guard.js": "548fc57131a04fa7",
|
||||
"hooks/lib/git-cmd.js": "268ba15992ca0b23",
|
||||
"hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9",
|
||||
"hooks/managed-hooks-registry.cjs": "0e7a61bde8688e11",
|
||||
"hooks/managed-hooks-registry.cjs": "9c0d837594c7b772",
|
||||
"package.json": "dbf8353f77358bc1",
|
||||
"scripts/changeset/README.md": "86ff89331dfd94b2",
|
||||
"scripts/changeset/cli.cjs": "68f92a344b199271",
|
||||
|
||||
@@ -331,11 +331,13 @@
|
||||
"hooks/gsd-statusline.js": "861808560e60b233",
|
||||
"hooks/gsd-update-banner.js": "b457746cb76c1957",
|
||||
"hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38",
|
||||
"hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd",
|
||||
"hooks/gsd-windsurf-pre-write.js": "92d4dbfbc36ab0cf",
|
||||
"hooks/gsd-workflow-guard.js": "59b46a74d19d58d3",
|
||||
"hooks/gsd-worktree-path-guard.js": "108ab88ccbafc5d8",
|
||||
"hooks/lib/git-cmd.js": "268ba15992ca0b23",
|
||||
"hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9",
|
||||
"hooks/managed-hooks-registry.cjs": "a494d1a70ed87690",
|
||||
"hooks/managed-hooks-registry.cjs": "bc58c3a7609d7eae",
|
||||
"package.json": "dbf8353f77358bc1",
|
||||
"scripts/changeset/README.md": "86ff89331dfd94b2",
|
||||
"scripts/changeset/cli.cjs": "68f92a344b199271",
|
||||
|
||||
@@ -21,11 +21,13 @@
|
||||
".kimi/hooks/gsd-statusline.js": "2736b0885aa97bbf",
|
||||
".kimi/hooks/gsd-update-banner.js": "55143a25f978f301",
|
||||
".kimi/hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38",
|
||||
".kimi/hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd",
|
||||
".kimi/hooks/gsd-windsurf-pre-write.js": "92d4dbfbc36ab0cf",
|
||||
".kimi/hooks/gsd-workflow-guard.js": "91ae24a15d2bca6f",
|
||||
".kimi/hooks/gsd-worktree-path-guard.js": "cfde29a547677422",
|
||||
".kimi/hooks/lib/git-cmd.js": "268ba15992ca0b23",
|
||||
".kimi/hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9",
|
||||
".kimi/hooks/managed-hooks-registry.cjs": "07c6a5ecd724edb3",
|
||||
".kimi/hooks/managed-hooks-registry.cjs": "08ec2585a3f8f132",
|
||||
".kimi/package.json": "dbf8353f77358bc1",
|
||||
"agents/gsd.md": "60fee7782ae4f2c6",
|
||||
"agents/gsd.yaml": "253a23ddda06c6c2",
|
||||
|
||||
@@ -402,11 +402,13 @@
|
||||
"hooks/gsd-statusline.js": "9c132b5985800462",
|
||||
"hooks/gsd-update-banner.js": "55143a25f978f301",
|
||||
"hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38",
|
||||
"hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd",
|
||||
"hooks/gsd-windsurf-pre-write.js": "92d4dbfbc36ab0cf",
|
||||
"hooks/gsd-workflow-guard.js": "91ae24a15d2bca6f",
|
||||
"hooks/gsd-worktree-path-guard.js": "726fb9afefda5d42",
|
||||
"hooks/lib/git-cmd.js": "268ba15992ca0b23",
|
||||
"hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9",
|
||||
"hooks/managed-hooks-registry.cjs": "9163e096b74ec4b3",
|
||||
"hooks/managed-hooks-registry.cjs": "bd57cc72f482a14f",
|
||||
"opencode.json": "2c12c446a88f2f36",
|
||||
"package.json": "dbf8353f77358bc1",
|
||||
"plugins/gsd-core.js": "931ca839dc9eb7f1",
|
||||
|
||||
@@ -331,11 +331,13 @@
|
||||
"hooks/gsd-statusline.js": "739140996a3c0d49",
|
||||
"hooks/gsd-update-banner.js": "b457746cb76c1957",
|
||||
"hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38",
|
||||
"hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd",
|
||||
"hooks/gsd-windsurf-pre-write.js": "92d4dbfbc36ab0cf",
|
||||
"hooks/gsd-workflow-guard.js": "59b46a74d19d58d3",
|
||||
"hooks/gsd-worktree-path-guard.js": "8389e4c9175b2613",
|
||||
"hooks/lib/git-cmd.js": "268ba15992ca0b23",
|
||||
"hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9",
|
||||
"hooks/managed-hooks-registry.cjs": "a5a93d50c4ea7a0c",
|
||||
"hooks/managed-hooks-registry.cjs": "08741ed76f1d8970",
|
||||
"package.json": "dbf8353f77358bc1",
|
||||
"scripts/changeset/README.md": "86ff89331dfd94b2",
|
||||
"scripts/changeset/cli.cjs": "68f92a344b199271",
|
||||
|
||||
@@ -311,6 +311,8 @@
|
||||
"gsd-core/workflows/validate-phase.md": "2db47bf5547d7b9d",
|
||||
"gsd-core/workflows/verify-phase.md": "f961cdb3ef03ff05",
|
||||
"gsd-core/workflows/verify-work.md": "cce8ae44b30957f1",
|
||||
"hooks/gsd-windsurf-pre-command.js": "7467a8a63e354aad",
|
||||
"hooks/gsd-windsurf-pre-write.js": "1c8a776d7ae2dcce",
|
||||
"scripts/changeset/README.md": "86ff89331dfd94b2",
|
||||
"scripts/changeset/cli.cjs": "68f92a344b199271",
|
||||
"scripts/changeset/github-release-notes.cjs": "795677f0c009b132",
|
||||
|
||||
374
tests/windsurf-hooks-bridge.test.cjs
Normal file
374
tests/windsurf-hooks-bridge.test.cjs
Normal file
@@ -0,0 +1,374 @@
|
||||
'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 { spawnSync, execFileSync } = require('node:child_process');
|
||||
|
||||
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));
|
||||
return spawnSync(process.execPath, [scriptPath], {
|
||||
input,
|
||||
encoding: 'utf8',
|
||||
timeout: 10000,
|
||||
cwd: opts.cwd || os.tmpdir(),
|
||||
});
|
||||
}
|
||||
|
||||
function initGitRepo(dir) {
|
||||
execFileSync('git', ['init', '-q', '.'], { cwd: dir, stdio: 'pipe' });
|
||||
execFileSync('git', ['config', 'user.email', 'gsd-test@example.com'], { cwd: dir, stdio: 'pipe' });
|
||||
execFileSync('git', ['config', 'user.name', 'gsd-test'], { cwd: dir, stdio: 'pipe' });
|
||||
execFileSync('git', ['commit', '--allow-empty', '-q', '-m', 'init'], { cwd: dir, stdio: 'pipe' });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 });
|
||||
|
||||
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 });
|
||||
|
||||
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 completes in well under 1s via the length cap', () => {
|
||||
const payload = `rm -${'r'.repeat(200000)}!`;
|
||||
const start = Date.now();
|
||||
const result = runHook(PRE_COMMAND_SCRIPT, { tool_info: { command_line: payload } });
|
||||
const elapsedMs = Date.now() - start;
|
||||
assert.equal(result.status, 0, `expected exit 0 (length-capped allow), got ${result.status} (stderr: ${result.stderr})`);
|
||||
assert.ok(elapsedMs < 1000, `expected < 1000ms, took ${elapsedMs}ms`);
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -43,6 +43,12 @@ const MODULE_OWNED_HOOKS = new Set([
|
||||
'gsd-cursor-stop.js',
|
||||
'gsd-cursor-subagent-start.js',
|
||||
'gsd-cursor-subagent-stop.js',
|
||||
// Windsurf/Cascade blocking hooks — registered by writeWindsurfHooksJson via the
|
||||
// WINDSURF_EVENT_SCRIPT_MAP indirection (src/runtime-hooks-surface.cts), never a
|
||||
// literal buildHookCommand(..., '<hook-name>', ...) call this source-scan matches.
|
||||
// Validated behaviorally by tests/windsurf-hooks-bridge.test.cjs.
|
||||
'gsd-windsurf-pre-write.js',
|
||||
'gsd-windsurf-pre-command.js',
|
||||
// gsd-check-update-worker.js is an implementation detail of gsd-check-update.js
|
||||
// (spawned internally via child_process.spawn), never itself registered as a
|
||||
// hook entry point.
|
||||
|
||||
Reference in New Issue
Block a user