* test(#3333): fold the runtime & install surface fix-* cluster — Wave 1 Folds 11 legacy tests/fix-*.test.cjs regression files into their module's main test suite: 6 folded into existing suites (host-integration-descriptors, effort-surface-axis, trae-imperative-reference, hermes-skills-migration, gsd-agent-isolation-guard), 5 renamed to become the module's sole suite (cursor-hook-workspace-roots, cursor-subagent-isolation, lint-compiled-artifact-sync, hooks-commonjs-marker, shared-hooks-dir-resolution). All 195 test() blocks preserved with zero drops; lint-test-file-count.cjs and eslint remain clean. No production code changed. Wave 1 of 7 in #3315 (H3 of epic #3053). * test(#3333): replace try/finally with t.after() in isolation-guard tests CONTRIBUTING.md bans try/finally inside test bodies (masks failures, not an approved pattern). The fold in the prior commit carried 27 instances forward verbatim from the deleted fix-3045-dispatch-isolation-resolver.test.cjs into an otherwise-clean file. Converts each to the approved per-test t.after() cleanup pattern — same cleanup call, registered instead of finally-wrapped. No assertion, fixture, or test-name change; test( count unchanged at 50. Found by the Standards review pass on Wave 1 (#3333, H3 of epic #3053). * fix(#3333): restore raw NUL byte mangled by the fold in hermes-skills-migration.test.cjs The prior fold commit copied fix-2284-hermes-agent-delegate-task-projection's "collision-robust" test via a text-based Read/Write pipeline, which silently turned a raw NUL byte (0x00) embedded in two string literals into a regular space character. That corrupted the test's actual purpose (proving a NUL byte survives a string-rewrite operation untouched) and produced a genuine gsd-test failure: `24 !== 1` for `out.split(' ').length`, because splitting on a space finds every space in the sentence instead of the single NUL byte the test meant to isolate. Root-caused by diffing the raw bytes (via `git cat-file blob` + `cat -v`) between the pre-fold source and the folded target — confirmed exactly two bytes differ. Restored via a byte-precise patch (latin1 round-trip) touching only those two lines; test( count and every other byte unchanged. * fix(#3333): use \x00 escape sequence instead of a raw NUL byte in test fixture The prior commit restored a byte-exact raw NUL byte matching the original fix-2284 source, and the production function (applyClaudeCodeBrandSwap) was confirmed correct in a standalone repro. But the same raw byte still failed through gsd-test's remote pipeline. Root cause is upstream of gsd-core: some step in that transfer path does not carry a raw 0x00 byte through untouched. A raw embedded NUL byte was never necessary here — `\x00` as a 4-character escape sequence in the source text produces the identical runtime character (U+0000) without ever putting a raw byte in the tracked file, sidestepping any byte-oriented transfer step. Applied at both call sites (the fixture string and the split() delimiter). No behavior change; test( count unchanged at 76. * fix(#3333): harden copyWithPathReplacement against a source file vanishing mid-copy (TOCTOU) Surfaced by this PR's own gsd-test run: tests/install-minimal-hooks.test.cjs and tests/opencode-command-dir-plural.test.cjs intermittently crashed with ENOENT reading gsd-core/workflows/zzz-e5-drift-fixture.md. Root cause is unrelated to test-file consolidation — tests/planning-prompt-drift.test.cjs writes that fixture directly into the real, shared gsd-core/workflows/ tree (main() hardcodes its scan root to the real repo) and deletes it in t.after(); copyWithPathReplacement's readdirSync-then-read loop has no protection against the listed file vanishing before it gets there, so a concurrently-running install path can crash entirely on what is otherwise a completely benign race. Fixed by skipping (not crashing on) a listed entry that no longer exists by the time the loop reaches it. Added a regression test that deterministically reproduces the race (readdirSync snapshot still lists the file; it is deleted immediately after) and proves both outcomes: no throw, and the vanished entry's destination is never partially written. Per CLAUDE.md's no-defer rule, a defect surfaced while verifying this PR is fixed inline rather than deferred — this overrides one-concern-per-PR. * fix(#3333): fix third NUL-byte-mangled occurrence missed by prior fix passes The fold originally mangled three raw-NUL-byte occurrences to spaces, not two — the earlier byte-restore and escape-sequence commits both only targeted the fixture string and the split() delimiter, missing out.includes('[ ]') a few lines below (should read out.includes('[\x00]')). A remote gsd-test run kept failing on this exact assertion even after both prior fixes, which is what surfaced the miss. Verified via a standalone repro using the file's real (not retyped) fixture content: all six assertions in the collision-robust test now pass. Zero raw NUL bytes remain in the file; test( count unchanged at 76. * chore(#3333): add changeset for the copyWithPathReplacement TOCTOU fix Fixed-type fragment for the production defect fixed inline in this PR (bin/install.js's copyWithPathReplacement). Exempt from docs/ requirements per CONTRIBUTING.md (only Added/Changed/Deprecated/Removed require it). * chore(#3333): backfill changeset PR number (pr:0 -> pr:3341) --------- Co-authored-by: sim <sim@local>
361 lines
14 KiB
JavaScript
361 lines
14 KiB
JavaScript
/**
|
|
* #2587 — Cursor sessionStart/stop hooks resolved .planning/ from process.cwd().
|
|
*
|
|
* Under the cursor-agent CLI, hooks are invoked with cwd set to the Cursor
|
|
* config dir (~/.cursor), NOT the workspace. Both hooks did:
|
|
*
|
|
* path.join(process.cwd(), '.planning', 'STATE.md')
|
|
*
|
|
* so the lookup always missed: gsd-cursor-session-start.js could only ever emit
|
|
* the "no .planning/ workflow found" nudge, and gsd-cursor-stop.js's verify-work
|
|
* reminder could never fire — even with .planning/STATE.md right there in the
|
|
* workspace. Both hooks already buffered stdin into `raw` but never parsed it;
|
|
* the payload's `workspace_roots` carries the real path.
|
|
*
|
|
* These are BEHAVIORAL tests: each spawns the real hook script as a child
|
|
* process with a cwd that does NOT contain .planning/ and a stdin payload whose
|
|
* workspace_roots does — exactly the CLI invocation shape from the report — and
|
|
* asserts on the emitted JSON contract. They fail against the pre-fix scripts.
|
|
*/
|
|
|
|
// allow-test-rule: source-text-is-the-product #2587 — the parity check (T8) compares the shared
|
|
// resolver text across the two standalone hook scripts, which is what Cursor loads.
|
|
|
|
'use strict';
|
|
|
|
process.env.GSD_TEST_MODE = '1';
|
|
|
|
const { test, describe } = 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 { createTempDir, cleanup } = require('./helpers.cjs');
|
|
const { runHook: runHookSeam } = require('./helpers/process-seam.cjs');
|
|
|
|
const HOOKS = path.join(__dirname, '..', 'hooks');
|
|
const SESSION_START = path.join(HOOKS, 'gsd-cursor-session-start.js');
|
|
const STOP = path.join(HOOKS, 'gsd-cursor-stop.js');
|
|
// subagentStart carried the identical defect — it was not named in the report
|
|
// but its cwd lookup meant every Cursor subagent (planner, executor, verifier)
|
|
// started without phase context under the CLI.
|
|
const SUBAGENT_START = path.join(HOOKS, 'gsd-cursor-subagent-start.js');
|
|
// Every cursor hook that resolves .planning/ from the payload. Kept as one list
|
|
// so a future hook added to this family is not silently left on the old path.
|
|
const RESOLVING_HOOKS = [SESSION_START, STOP, SUBAGENT_START];
|
|
|
|
const MSG_PRESENT_FRAGMENT = '.planning/STATE.md is present';
|
|
const MSG_ABSENT_FRAGMENT = 'no .planning/ workflow found';
|
|
const STOP_REMINDER_FRAGMENT = 'Agent stopping';
|
|
|
|
/** Run a hook script with an explicit cwd and stdin payload; return parsed stdout JSON. */
|
|
function runHook(script, { cwd, payload }) {
|
|
const r = runHookSeam(script, [], {
|
|
cwd,
|
|
input: typeof payload === 'string' ? payload : JSON.stringify(payload),
|
|
timeoutMs: 20000,
|
|
});
|
|
return JSON.parse(r.stdout || '{}');
|
|
}
|
|
|
|
/** A directory containing .planning/STATE.md. */
|
|
function makeWorkspace(withPlanning) {
|
|
const dir = createTempDir('gsd-2587-');
|
|
if (withPlanning) {
|
|
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
|
|
fs.writeFileSync(path.join(dir, '.planning', 'STATE.md'), '# Project State\n');
|
|
}
|
|
return dir;
|
|
}
|
|
|
|
describe('#2587: cursor hooks resolve the workspace from workspace_roots, not cwd', () => {
|
|
test('sessionStart: cwd is the Cursor config dir, workspace_roots carries the project', () => {
|
|
const workspace = makeWorkspace(true);
|
|
const cursorConfigDir = makeWorkspace(false); // stands in for ~/.cursor
|
|
try {
|
|
const out = runHook(SESSION_START, {
|
|
cwd: cursorConfigDir,
|
|
payload: {
|
|
hook_event_name: 'sessionStart',
|
|
cursor_version: '2026.07.23-e383d2b',
|
|
is_background_agent: false,
|
|
workspace_roots: [workspace],
|
|
transcript_path: null,
|
|
},
|
|
});
|
|
assert.match(
|
|
out.additional_context || '',
|
|
new RegExp(MSG_PRESENT_FRAGMENT.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')),
|
|
'must report STATE.md present when workspace_roots points at the project',
|
|
);
|
|
} finally {
|
|
cleanup(workspace);
|
|
cleanup(cursorConfigDir);
|
|
}
|
|
});
|
|
|
|
test('stop: verify-work reminder fires when workspace_roots carries the project', () => {
|
|
const workspace = makeWorkspace(true);
|
|
const cursorConfigDir = makeWorkspace(false);
|
|
try {
|
|
const out = runHook(STOP, {
|
|
cwd: cursorConfigDir,
|
|
payload: { hook_event_name: 'stop', workspace_roots: [workspace] },
|
|
});
|
|
assert.ok(
|
|
(out.additional_context || '').includes(STOP_REMINDER_FRAGMENT),
|
|
'stop hook must emit its verify-work reminder for the real workspace',
|
|
);
|
|
} finally {
|
|
cleanup(workspace);
|
|
cleanup(cursorConfigDir);
|
|
}
|
|
});
|
|
|
|
// Boundary coverage on the workspace_roots array: 0, 1, and 2 entries.
|
|
|
|
test('zero roots: falls back to cwd (preserves IDE behavior)', () => {
|
|
const workspace = makeWorkspace(true);
|
|
try {
|
|
const out = runHook(SESSION_START, {
|
|
cwd: workspace,
|
|
payload: { hook_event_name: 'sessionStart', workspace_roots: [] },
|
|
});
|
|
assert.ok(
|
|
(out.additional_context || '').includes(MSG_PRESENT_FRAGMENT),
|
|
'an empty workspace_roots must fall back to cwd, not break the IDE path',
|
|
);
|
|
} finally {
|
|
cleanup(workspace);
|
|
}
|
|
});
|
|
|
|
test('one root, no .planning anywhere: reports absent', () => {
|
|
const workspace = makeWorkspace(false);
|
|
const cursorConfigDir = makeWorkspace(false);
|
|
try {
|
|
const out = runHook(SESSION_START, {
|
|
cwd: cursorConfigDir,
|
|
payload: { hook_event_name: 'sessionStart', workspace_roots: [workspace] },
|
|
});
|
|
assert.ok(
|
|
(out.additional_context || '').includes(MSG_ABSENT_FRAGMENT),
|
|
'a genuinely project-less workspace must still nudge toward new-project',
|
|
);
|
|
} finally {
|
|
cleanup(workspace);
|
|
cleanup(cursorConfigDir);
|
|
}
|
|
});
|
|
|
|
test('two roots: resolves the one that actually carries .planning/', () => {
|
|
const plain = makeWorkspace(false);
|
|
const withPlanning = makeWorkspace(true);
|
|
const cursorConfigDir = makeWorkspace(false);
|
|
try {
|
|
const out = runHook(SESSION_START, {
|
|
cwd: cursorConfigDir,
|
|
// GSD project is NOT the first root — first-root-only would miss it.
|
|
payload: { hook_event_name: 'sessionStart', workspace_roots: [plain, withPlanning] },
|
|
});
|
|
assert.ok(
|
|
(out.additional_context || '').includes(MSG_PRESENT_FRAGMENT),
|
|
'multi-root: the root carrying .planning/ must win over mere ordering',
|
|
);
|
|
} finally {
|
|
cleanup(plain);
|
|
cleanup(withPlanning);
|
|
cleanup(cursorConfigDir);
|
|
}
|
|
});
|
|
|
|
test('malformed stdin JSON: fails open to cwd instead of crashing', () => {
|
|
const workspace = makeWorkspace(true);
|
|
try {
|
|
const out = runHook(SESSION_START, { cwd: workspace, payload: '{not valid json' });
|
|
assert.ok(
|
|
(out.additional_context || '').includes(MSG_PRESENT_FRAGMENT),
|
|
'a malformed payload must degrade to cwd, never wedge the session',
|
|
);
|
|
} finally {
|
|
cleanup(workspace);
|
|
}
|
|
});
|
|
|
|
test('non-string and empty root entries are ignored', () => {
|
|
const workspace = makeWorkspace(true);
|
|
const cursorConfigDir = makeWorkspace(false);
|
|
try {
|
|
const out = runHook(SESSION_START, {
|
|
cwd: cursorConfigDir,
|
|
payload: {
|
|
hook_event_name: 'sessionStart',
|
|
workspace_roots: [null, '', 42, workspace],
|
|
},
|
|
});
|
|
assert.ok(
|
|
(out.additional_context || '').includes(MSG_PRESENT_FRAGMENT),
|
|
'junk entries must be filtered rather than resolved as paths',
|
|
);
|
|
} finally {
|
|
cleanup(workspace);
|
|
cleanup(cursorConfigDir);
|
|
}
|
|
});
|
|
|
|
test('subagentStart: reminder resolves via workspace_roots (missed site)', () => {
|
|
const workspace = makeWorkspace(true);
|
|
const cursorConfigDir = makeWorkspace(false);
|
|
try {
|
|
const out = runHook(SUBAGENT_START, {
|
|
cwd: cursorConfigDir,
|
|
payload: { hook_event_name: 'subagentStart', workspace_roots: [workspace] },
|
|
});
|
|
assert.match(
|
|
out.additional_context || '',
|
|
/review \.planning\/STATE\.md/,
|
|
'subagents must receive phase context, not the absent nudge',
|
|
);
|
|
} finally {
|
|
cleanup(workspace);
|
|
cleanup(cursorConfigDir);
|
|
}
|
|
});
|
|
|
|
test('stop: absent branch still emits {} when no root and no cwd has .planning', () => {
|
|
const workspace = makeWorkspace(false);
|
|
const cursorConfigDir = makeWorkspace(false);
|
|
try {
|
|
const out = runHook(STOP, {
|
|
cwd: cursorConfigDir,
|
|
payload: { hook_event_name: 'stop', workspace_roots: [workspace] },
|
|
});
|
|
assert.deepEqual(
|
|
out,
|
|
{},
|
|
'stop must stay silent when there is genuinely no GSD project',
|
|
);
|
|
} finally {
|
|
cleanup(workspace);
|
|
cleanup(cursorConfigDir);
|
|
}
|
|
});
|
|
|
|
test('cwd is a candidate, not just the empty-roots fallback', () => {
|
|
// Regression guard: resolving ONLY over workspace_roots would report absent
|
|
// whenever roots are supplied but the project actually sits at cwd — a
|
|
// NARROWING versus the pre-fix behavior, which always consulted cwd.
|
|
const projectAtCwd = makeWorkspace(true);
|
|
const unrelatedRoot = makeWorkspace(false);
|
|
try {
|
|
for (const hook of RESOLVING_HOOKS) {
|
|
const out = runHook(hook, {
|
|
cwd: projectAtCwd,
|
|
payload: { hook_event_name: 'sessionStart', workspace_roots: [unrelatedRoot] },
|
|
});
|
|
// stop's present-branch is its verify-work reminder, not a STATE.md phrase.
|
|
const ctx = out.additional_context || '';
|
|
assert.ok(
|
|
/STATE\.md is present|review \.planning\/STATE\.md|Agent stopping/.test(ctx),
|
|
`${path.basename(hook)}: a project at cwd must still be found when roots miss`,
|
|
);
|
|
}
|
|
} finally {
|
|
cleanup(projectAtCwd);
|
|
cleanup(unrelatedRoot);
|
|
}
|
|
});
|
|
|
|
test('single source: every hook requires the shared resolver, none redefines it', () => {
|
|
// The resolver lives in hooks/lib/cursor-workspace.js. Divergence is
|
|
// prevented structurally (one implementation) rather than by a parity
|
|
// assertion over copies, so this guards the structure: no hook may grow a
|
|
// local copy back.
|
|
for (const file of RESOLVING_HOOKS) {
|
|
const src = fs.readFileSync(file, 'utf8');
|
|
assert.ok(
|
|
src.includes("require('./lib/cursor-workspace.js')"),
|
|
`${path.basename(file)} must use the shared resolver`,
|
|
);
|
|
assert.ok(
|
|
!src.includes('function resolveWorkspaceRoot('),
|
|
`${path.basename(file)} must not redefine resolveWorkspaceRoot locally`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('staging fails loudly if a required lib source is missing', () => {
|
|
// Previously this path did `continue`, so a helper missing from source
|
|
// (typo, bad rebase, accidental delete) produced an install that exits 0 and
|
|
// ships hooks whose top-level require() throws MODULE_NOT_FOUND at load —
|
|
// before their own try/catch — wedging every session, with nothing to
|
|
// indicate why. Packaging bugs must surface at install, not at the user.
|
|
const hooksSurface = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs');
|
|
const fakeSrc = createTempDir('gsd-2587-src-');
|
|
const target = createTempDir('gsd-2587-tgt-');
|
|
try {
|
|
// A source tree with the hook scripts but NO hooks/lib/ backing them.
|
|
const srcHooks = path.join(fakeSrc, 'hooks');
|
|
fs.mkdirSync(srcHooks, { recursive: true });
|
|
for (const hook of RESOLVING_HOOKS) {
|
|
fs.copyFileSync(hook, path.join(srcHooks, path.basename(hook)));
|
|
}
|
|
assert.throws(
|
|
() => hooksSurface.writeCursorHooksJson(target, fakeSrc, {}),
|
|
/cursor-workspace\.js.*missing|missing.*cursor-workspace\.js/s,
|
|
'a missing lib source must abort the install, not ship a broken hook',
|
|
);
|
|
} finally {
|
|
cleanup(fakeSrc);
|
|
cleanup(target);
|
|
}
|
|
});
|
|
|
|
test('the shared resolver is staged next to the hooks that require it', () => {
|
|
// The MODULE_NOT_FOUND guard. Cursor sets skipSharedHooksInstall, so it
|
|
// never reaches the installer's bulk hooks/lib copy — every other runtime
|
|
// that ships these hooks does. If writeCursorHooksJson stopped staging the
|
|
// helper, each hook would throw at require time, BEFORE its own try/catch,
|
|
// and wedge every Cursor session on the one runtime this fix exists for.
|
|
const { runMinimalInstall } = require('./helpers/install-shared.cjs');
|
|
const { configDir, root } = runMinimalInstall({ runtime: 'cursor', scope: 'global' });
|
|
try {
|
|
const staged = path.join(configDir, 'hooks', 'lib', 'cursor-workspace.js');
|
|
assert.ok(
|
|
fs.existsSync(staged),
|
|
'cursor install must stage hooks/lib/cursor-workspace.js next to the hook scripts',
|
|
);
|
|
// And the staged hook must actually load against it.
|
|
const hook = path.join(configDir, 'hooks', 'gsd-cursor-session-start.js');
|
|
assert.ok(fs.existsSync(hook), 'cursor install must stage the sessionStart hook');
|
|
const ws = makeWorkspace(true);
|
|
try {
|
|
const out = JSON.parse(execFileSync(process.execPath, [hook], {
|
|
cwd: root,
|
|
input: JSON.stringify({ workspace_roots: [ws] }),
|
|
encoding: 'utf8',
|
|
timeout: 20000,
|
|
}) || '{}');
|
|
assert.ok(
|
|
(out.additional_context || '').includes('STATE.md is present'),
|
|
'the INSTALLED hook must resolve the workspace, not crash on a missing helper',
|
|
);
|
|
} finally {
|
|
cleanup(ws);
|
|
}
|
|
} finally {
|
|
cleanup(root);
|
|
}
|
|
});
|
|
|
|
test('no cursor hook resolves .planning from process.cwd() directly', () => {
|
|
for (const file of RESOLVING_HOOKS) {
|
|
const src = fs.readFileSync(file, 'utf8');
|
|
assert.ok(
|
|
!/path\.join\(\s*process\.cwd\(\)\s*,\s*'\.planning'/.test(src),
|
|
`${path.basename(file)}: must not resolve .planning from cwd (#2587)`,
|
|
);
|
|
}
|
|
});
|
|
});
|