Files
msd-core/tests/shell-command-projection-dispatch.test.cjs
Tom Boucher e35534c3ec fix(#2236): add hookShell parameter for PowerShell call operator (#2261)
* fix(#2236): add hookShell parameter for PowerShell call operator

Windows Claude Code with a PowerShell hook runner failed on every hook
with 'Unexpected token' — the installer emitted bare quoted paths that
PowerShell parses as string literals, not commands.

The root cause was that hookCommandNeedsPowerShellCallOperator returned
false unconditionally — the &/no-& decision was keyed on (platform,
runtime) only, with no signal for the effective hook-execution shell.
One runtime (Claude Code) hosts either Git Bash or PowerShell on Windows.

Fix: thread a new hookShell parameter through the projection chain.
When hookShell='powershell', the & call operator is prepended. Default
(Git Bash, no prefix) is unchanged and regression-locked.

* docs: backfill changeset PR number (#2261)

* merge: keep up to date with next

* fix: regenerate stale capability-registry after next merge
2026-07-14 15:16:55 -04:00

1601 lines
66 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const fs = require('node:fs');
const {
execGit,
execNpm,
execTool,
probeTty,
normalizeContent,
platformWriteSync,
platformReadSync,
platformEnsureDir,
dispatchGsdCommand,
resolveGsdToolsPath,
} = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'shell-command-projection.cjs'));
const { createTempGitProject, createTempDir, cleanup } = require('./helpers.cjs');
// ─── execGit ─────────────────────────────────────────────────────────────────
describe('execGit', () => {
let tmpDir;
beforeEach(() => { tmpDir = createTempGitProject(); });
afterEach(() => { cleanup(tmpDir); });
test('returns { exitCode, stdout, stderr } shape', () => {
const result = execGit(['--version']);
assert.ok(Object.prototype.hasOwnProperty.call(result, 'exitCode'), 'missing exitCode');
assert.ok(Object.prototype.hasOwnProperty.call(result, 'stdout'), 'missing stdout');
assert.ok(Object.prototype.hasOwnProperty.call(result, 'stderr'), 'missing stderr');
});
test('exitCode 0 for successful command', () => {
const result = execGit(['--version']);
assert.strictEqual(result.exitCode, 0);
});
test('stdout contains version string for --version', () => {
const result = execGit(['--version']);
assert.strictEqual(typeof result.stdout, 'string');
assert.ok(result.stdout.length > 0, 'stdout should not be empty for git --version');
});
test('exitCode non-zero for failing command — does not throw', () => {
const result = execGit(['status', '--porcelain'], { cwd: '/tmp/definitely-not-a-git-repo-8675309' });
assert.notStrictEqual(result.exitCode, 0);
});
test('respects cwd option', () => {
const result = execGit(['status', '--porcelain'], { cwd: tmpDir });
assert.strictEqual(result.exitCode, 0);
});
});
// ─── execNpm ─────────────────────────────────────────────────────────────────
describe('execNpm', () => {
test('returns { exitCode, stdout, stderr } shape', () => {
const result = execNpm(['--version']);
assert.ok(Object.prototype.hasOwnProperty.call(result, 'exitCode'), 'missing exitCode');
assert.ok(Object.prototype.hasOwnProperty.call(result, 'stdout'), 'missing stdout');
assert.ok(Object.prototype.hasOwnProperty.call(result, 'stderr'), 'missing stderr');
});
test('exitCode 0 for npm --version', () => {
const result = execNpm(['--version']);
assert.strictEqual(result.exitCode, 0);
});
test('stdout is non-empty for npm --version', () => {
const result = execNpm(['--version']);
assert.ok(result.stdout.trim().length > 0);
});
});
// ─── execTool ────────────────────────────────────────────────────────────────
describe('execTool', () => {
test('returns { exitCode, stdout, stderr } shape for known program', () => {
const result = execTool('node', ['--version']);
assert.ok(Object.prototype.hasOwnProperty.call(result, 'exitCode'), 'missing exitCode');
assert.ok(Object.prototype.hasOwnProperty.call(result, 'stdout'), 'missing stdout');
assert.ok(Object.prototype.hasOwnProperty.call(result, 'stderr'), 'missing stderr');
});
test('exitCode 0 for node --version', () => {
const result = execTool('node', ['--version']);
assert.strictEqual(result.exitCode, 0);
});
test('exitCode 127 and no throw when program does not exist', () => {
const result = execTool('definitely-not-a-real-program-8675309', []);
assert.strictEqual(result.exitCode, 127);
assert.strictEqual(result.stdout, '');
assert.strictEqual(typeof result.stderr, 'string');
});
});
// ─── dispatchGsdCommand (#2102 Stage 2 — subprocess-shim dispatch to gsd-tools.cjs) ──
//
// The command-routing hub (`createHub()`) has no fully-populated factory
// anywhere in the tree — every caller builds a single-family hub — so the
// only dispatch path covering the FULL family/subcommand surface is the
// gsd-tools.cjs CLI itself. This is the shared helper pi/gsd.cjs and the
// companion MCP server both dispatch through.
describe('dispatchGsdCommand', () => {
let tmpDir;
beforeEach(() => { tmpDir = createTempDir(); });
afterEach(() => { cleanup(tmpDir); });
test('resolveGsdToolsPath resolves to the real gsd-tools.cjs on disk', () => {
const toolsPath = resolveGsdToolsPath();
assert.ok(fs.existsSync(toolsPath), `expected gsd-tools.cjs to exist at ${toolsPath}`);
assert.equal(path.basename(toolsPath), 'gsd-tools.cjs');
});
test('a valid read-only family/subcommand dispatches for real and returns ok:true + non-empty stdout', () => {
const result = dispatchGsdCommand({ family: 'progress', subcommand: 'json', cwd: tmpDir });
assert.equal(result.ok, true, `expected ok:true, got: ${JSON.stringify(result)}`);
assert.equal(typeof result.stdout, 'string');
assert.ok(result.stdout.length > 0, 'stdout must be non-empty');
const parsed = JSON.parse(result.stdout);
assert.equal(typeof parsed.percent, 'number', 'the real progress command ran (proves the engine was reached)');
assert.equal(result.code, 0);
assert.equal(result.timedOut, false);
});
test('an unknown family returns ok:false without throwing', () => {
assert.doesNotThrow(() => {
const result = dispatchGsdCommand({ family: 'no-such-family-8675309', cwd: tmpDir });
assert.equal(result.ok, false);
assert.notEqual(result.code, 0);
assert.equal(typeof result.stderr, 'string');
assert.ok(result.stderr.length > 0);
// --json-errors gives a structured, parseable error envelope.
const parsedErr = JSON.parse(result.stderr);
assert.equal(parsedErr.ok, false);
});
});
test('a missing/bogus gsd-tools.cjs path degrades to ok:false without throwing', () => {
assert.doesNotThrow(() => {
const result = dispatchGsdCommand({
family: 'progress',
cwd: tmpDir,
gsdToolsPath: path.join(tmpDir, 'definitely-not-a-real-gsd-tools-8675309.cjs'),
});
assert.equal(result.ok, false);
assert.equal(result.timedOut, false);
});
});
test('a missing/empty "family" is rejected locally without spawning a subprocess', () => {
const result = dispatchGsdCommand({ cwd: tmpDir });
assert.equal(result.ok, false);
assert.equal(result.code, null);
assert.match(result.stderr, /requires a non-empty string "family"/);
});
test('a wall-clock timeout is reported via timedOut:true, ok:false — never throws', () => {
assert.doesNotThrow(() => {
const result = dispatchGsdCommand({ family: 'progress', subcommand: 'json', cwd: tmpDir, timeout: 1 });
assert.equal(result.ok, false);
assert.equal(result.timedOut, true);
});
});
});
// ─── probeTty ────────────────────────────────────────────────────────────────
describe('probeTty', () => {
test('returns string or null — never throws', () => {
const result = probeTty();
assert.ok(result === null || typeof result === 'string', `expected string|null, got ${typeof result}`);
});
test('returns null when platform is win32', () => {
const result = probeTty({ platform: 'win32' });
assert.strictEqual(result, null);
});
});
// ─── normalizeContent ────────────────────────────────────────────────────────
describe('normalizeContent', () => {
test('returns { content, encoding } shape', () => {
const result = normalizeContent('file.md', 'hello\n');
assert.ok(Object.prototype.hasOwnProperty.call(result, 'content'), 'missing content');
assert.ok(Object.prototype.hasOwnProperty.call(result, 'encoding'), 'missing encoding');
});
test('normalizes CRLF to LF for .md files', () => {
const result = normalizeContent('file.md', 'line1\r\nline2\r\n');
assert.ok(!result.content.includes('\r\n'), 'CRLF should be normalized to LF');
});
test('normalizes CRLF to LF for non-.md files', () => {
const result = normalizeContent('file.json', '{"a":1}\r\n');
assert.ok(!result.content.includes('\r\n'), 'CRLF should be normalized to LF');
});
test('enforces single trailing newline for .md files', () => {
const result = normalizeContent('file.md', 'hello');
assert.ok(result.content.endsWith('\n'), 'should end with newline');
assert.ok(!result.content.endsWith('\n\n'), 'should not end with double newline');
});
test('enforces single trailing newline for non-.md files', () => {
const result = normalizeContent('file.txt', 'hello');
assert.ok(result.content.endsWith('\n'));
assert.ok(!result.content.endsWith('\n\n'));
});
test('applies full markdownlint normalization for .md files — blank line before heading', () => {
const input = [
'# Title',
'paragraph',
'## Section',
].join('\n');
const result = normalizeContent('file.md', input);
assert.ok(result.content.includes('\n\n## Section'), 'MD022: blank line before heading');
});
test('does NOT apply markdown structural rules to non-.md files', () => {
const input = 'paragraph\n## Not a heading in json\n';
const result = normalizeContent('file.json', input);
assert.strictEqual(result.content, input);
});
test('encoding defaults to utf-8', () => {
const result = normalizeContent('file.md', 'hello\n');
assert.strictEqual(result.encoding, 'utf-8');
});
});
// ─── platformWriteSync ───────────────────────────────────────────────────────
describe('platformWriteSync', () => {
let tmpDir;
beforeEach(() => { tmpDir = createTempDir(); });
afterEach(() => { cleanup(tmpDir); });
test('written file exists and is a regular file', () => {
const filePath = path.join(tmpDir, 'output.md');
platformWriteSync(filePath, '# Hello\n');
assert.ok(fs.statSync(filePath).isFile());
});
test('written file has non-zero size', () => {
const filePath = path.join(tmpDir, 'output.md');
platformWriteSync(filePath, '# Hello\n');
assert.ok(fs.statSync(filePath).size > 0);
});
test('creates parent directory if absent', () => {
const filePath = path.join(tmpDir, 'nested', 'deep', 'output.md');
platformWriteSync(filePath, '# Hello\n');
assert.ok(fs.statSync(filePath).isFile());
});
test('mtime advances on re-write', (_t) => {
const filePath = path.join(tmpDir, 'output.md');
platformWriteSync(filePath, '# First\n');
const mtimeBefore = fs.statSync(filePath).mtimeMs;
// Small delay to ensure mtime differs
const start = Date.now();
while (Date.now() - start < 10) { /* busy wait */ }
platformWriteSync(filePath, '# Second\n');
const mtimeAfter = fs.statSync(filePath).mtimeMs;
assert.ok(mtimeAfter >= mtimeBefore, 'mtime should advance or stay same on re-write');
});
test('no temp file left on disk after successful write', () => {
const filePath = path.join(tmpDir, 'output.md');
platformWriteSync(filePath, '# Hello\n');
const tmpFiles = fs.readdirSync(tmpDir).filter(f => f.includes('.tmp.'));
assert.strictEqual(tmpFiles.length, 0, 'no temp files should remain');
});
});
// ─── platformReadSync ────────────────────────────────────────────────────────
describe('platformReadSync', () => {
let tmpDir;
beforeEach(() => { tmpDir = createTempDir(); });
afterEach(() => { cleanup(tmpDir); });
test('returns null for missing file when required is false (default)', () => {
const result = platformReadSync(path.join(tmpDir, 'nonexistent.md'));
assert.strictEqual(result, null);
});
test('throws for missing file when required is true', () => {
assert.throws(
() => platformReadSync(path.join(tmpDir, 'nonexistent.md'), { required: true }),
/ENOENT/,
);
});
test('returns string content for existing file', () => {
const filePath = path.join(tmpDir, 'existing.md');
fs.writeFileSync(filePath, '# Hello\n', 'utf-8');
const result = platformReadSync(filePath);
assert.strictEqual(typeof result, 'string');
assert.ok(result.length > 0);
});
});
// ─── platformEnsureDir ───────────────────────────────────────────────────────
describe('platformEnsureDir', () => {
let tmpDir;
beforeEach(() => { tmpDir = createTempDir(); });
afterEach(() => { cleanup(tmpDir); });
test('creates directory if absent', () => {
const dirPath = path.join(tmpDir, 'new', 'nested', 'dir');
platformEnsureDir(dirPath);
assert.ok(fs.statSync(dirPath).isDirectory());
});
test('no error when directory already exists — idempotent', () => {
const dirPath = path.join(tmpDir, 'existing');
fs.mkdirSync(dirPath);
assert.doesNotThrow(() => platformEnsureDir(dirPath));
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-1906-hook-relative-paths.test.cjs — consolidation epic #1969 (B3 #1972)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-1906-hook-relative-paths (consolidation epic #1969 B3 #1972)", () => {
/**
* Regression tests for bug #1906
*
* Local installs must anchor hook command paths to $CLAUDE_PROJECT_DIR so
* hooks resolve correctly regardless of the shell's current working directory.
*
* The original bug: local install hook commands used bare relative paths like
* `node .claude/hooks/gsd-context-monitor.js`. Claude Code persists the bash
* tool's cwd between calls, so a single `cd subdir && …` early in a session
* permanently broke every hook for the rest of that session.
*
* The fix prefixes all local hook commands with "$CLAUDE_PROJECT_DIR"/ so
* path resolution is always anchored to the project root.
*/
'use strict';
const { describe, test, before } = require('node:test');
const assert = require('node:assert/strict');
const path = require('path');
const projection = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'shell-command-projection.cjs'));
const { projectLocalHookPrefix, projectShellCommandText } = projection;
describe('bug #1906: local hook commands use $CLAUDE_PROJECT_DIR', () => {
before(() => {
assert.equal(typeof projectLocalHookPrefix, 'function');
assert.equal(typeof projectShellCommandText, 'function');
});
test('non-Gemini runtimes get $CLAUDE_PROJECT_DIR anchored local prefix', () => {
const prefix = projectLocalHookPrefix({ runtime: 'claude', dirName: '.claude' });
assert.equal(prefix, '"$CLAUDE_PROJECT_DIR"/.claude');
});
test('local command projection for non-Gemini keeps $CLAUDE_PROJECT_DIR anchor', () => {
const prefix = projectLocalHookPrefix({ runtime: 'claude', dirName: '.claude' });
const command = projectShellCommandText({
runnerToken: '"/usr/local/bin/node"',
argTokens: [`${prefix}/hooks/gsd-context-monitor.js`],
runtime: 'claude',
platform: 'linux',
});
assert.equal(
command,
'"/usr/local/bin/node" "$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-context-monitor.js',
);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-2557-gemini-local-hook-paths.test.cjs — consolidation epic #1969 (B3 #1972)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-2557-gemini-local-hook-paths (consolidation epic #1969 B3 #1972)", () => {
'use strict';
/**
* Bug #2557: Gemini CLI local hook commands must NOT use $CLAUDE_PROJECT_DIR.
*
* $CLAUDE_PROJECT_DIR is a Claude Code-specific env variable. Gemini CLI did
* not set it. On Windows, Gemini's own variable-substitution + path-join logic
* produced a doubled path like `D:\Projects\GSD\'D:\Projects\GSD'`, causing
* every local project hook to fail at SessionStart.
*
* Fix: localPrefix was made runtime-conditional. Gemini/Antigravity used bare
* dirName (relative path) since they always run project hooks with the project
* dir as cwd. Claude Code and others still use "$CLAUDE_PROJECT_DIR"/ (#1906).
*
* #1928: Google sunset Gemini CLI (2026-06-18) and the `gemini` runtime was
* removed from GSD entirely.
*
* #2096: `projectLocalHookPrefix` no longer special-cases `antigravity` by
* name — it branches on the caller-supplied `hookPathStyle` (sourced from
* the runtime's `hostBehaviors.hookPathStyle` descriptor field). An
* unrecognized runtime string like the former `'gemini'`, or any runtime
* that doesn't declare `hookPathStyle: 'raw'`, falls through to the default
* $CLAUDE_PROJECT_DIR-anchored prefix.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const projection = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'shell-command-projection.cjs'));
const { projectLocalHookPrefix, projectShellCommandText } = projection;
describe('bug #2557: Antigravity local hooks use relative paths (not $CLAUDE_PROJECT_DIR); gemini runtime removed (#1928)', () => {
test('Antigravity local prefix is bare dirName', () => {
// #2096: hookPathStyle is now the caller-supplied descriptor value
// (bin/install.js resolves it from hostBehaviors.hookPathStyle); the
// function itself no longer knows the runtime name 'antigravity'.
assert.equal(
projectLocalHookPrefix({ runtime: 'antigravity', dirName: '.agents', hookPathStyle: 'raw' }),
'.agents',
);
});
test('non-Antigravity local prefix remains $CLAUDE_PROJECT_DIR anchored', () => {
assert.equal(
projectLocalHookPrefix({ runtime: 'claude', dirName: '.claude' }),
'"$CLAUDE_PROJECT_DIR"/.claude',
);
});
test('an unrecognized runtime string (e.g. the former "gemini") falls through to $CLAUDE_PROJECT_DIR anchoring (#1928)', () => {
const prefix = projectLocalHookPrefix({ runtime: 'gemini', dirName: '.gemini' });
assert.equal(prefix, '"$CLAUDE_PROJECT_DIR"/.gemini');
const command = projectShellCommandText({
runnerToken: '"/usr/local/bin/node"',
argTokens: [`${prefix}/hooks/gsd-check-update.js`],
runtime: 'gemini',
platform: 'linux',
});
assert.ok(
command.includes('$CLAUDE_PROJECT_DIR'),
'an unrecognized runtime must use the default anchored prefix, not the retired Gemini-only bare-path carve-out',
);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-3413-shell-command-projection.test.cjs — consolidation epic #1969 (B3 #1972)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-3413-shell-command-projection (consolidation epic #1969 B3 #1972)", () => {
'use strict';
process.env.GSD_TEST_MODE = '1';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const projection = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'shell-command-projection.cjs'));
const install = require(path.join(__dirname, '..', 'bin', 'install.js'));
const {
hookCommandNeedsPowerShellCallOperator,
formatHookCommandForRuntime,
isManagedHookBasename,
isManagedHookCommand,
projectLocalHookPrefix,
projectLegacySettingsHookCommand,
projectPortableHookBaseDir,
} = projection;
const { buildHookCommand, rewriteLegacyManagedNodeHookCommands } = install;
describe('bug #3413: Shell Command Projection Module uses runtime-aware hook policy', () => {
test('#1928: no current runtime needs the PowerShell call operator (seam inert after gemini removal)', () => {
// Gemini CLI was the only runtime that needed `& ` on Windows; it was
// removed (#1928, Google sunset 2026-06-18). Antigravity — the Gemini-backend
// successor — never matched the old check, so it stays prefix-free. Lock the
// now-inert contract so a future re-enable is a deliberate change.
assert.equal(
hookCommandNeedsPowerShellCallOperator({ platform: 'win32', runtime: 'antigravity' }),
false,
);
assert.equal(
formatHookCommandForRuntime('"C:/node.exe" "C:/hook.js"', { platform: 'win32', runtime: 'antigravity' }),
'"C:/node.exe" "C:/hook.js"',
);
});
test('Claude on Windows stays shell-neutral', () => {
assert.equal(
hookCommandNeedsPowerShellCallOperator({ platform: 'win32', runtime: 'claude' }),
false,
);
assert.equal(
formatHookCommandForRuntime('"C:/node.exe" "C:/hook.js"', { platform: 'win32', runtime: 'claude' }),
'"C:/node.exe" "C:/hook.js"',
);
});
test('runtime omitted stays conservative (no PowerShell prefix)', () => {
assert.equal(
formatHookCommandForRuntime('"C:/node.exe" "C:/hook.js"', { platform: 'win32' }),
'"C:/node.exe" "C:/hook.js"',
);
});
});
describe('bug #2236: hookShell parameter routes PowerShell call operator', () => {
test('hookShell=powershell on Windows returns true for any runtime', () => {
assert.equal(
hookCommandNeedsPowerShellCallOperator({ platform: 'win32', runtime: 'claude', hookShell: 'powershell' }),
true,
'PowerShell hook shell must trigger the call operator regardless of runtime',
);
assert.equal(
hookCommandNeedsPowerShellCallOperator({ platform: 'win32', runtime: 'antigravity', hookShell: 'powershell' }),
true,
);
});
test('hookShell=bash (default) stays false — Git-Bash form preserved', () => {
assert.equal(
hookCommandNeedsPowerShellCallOperator({ platform: 'win32', runtime: 'claude', hookShell: 'bash' }),
false,
);
assert.equal(
hookCommandNeedsPowerShellCallOperator({ platform: 'win32', runtime: 'claude' }),
false,
);
});
test('formatHookCommandForRuntime prepends & when hookShell=powershell', () => {
const cmd = '"C:/Program Files/nodejs/node.exe" "C:/Users/me/.claude/hooks/gsd-context-monitor.js"';
assert.equal(
formatHookCommandForRuntime(cmd, { platform: 'win32', runtime: 'claude', hookShell: 'powershell' }),
`& ${cmd}`,
);
});
test('formatHookCommandForRuntime omits & when hookShell unset (backward compat)', () => {
const cmd = '"C:/Program Files/nodejs/node.exe" "C:/Users/me/.claude/hooks/gsd-context-monitor.js"';
assert.equal(
formatHookCommandForRuntime(cmd, { platform: 'win32', runtime: 'claude' }),
cmd,
);
});
test('buildHookCommand emits & prefix when hookShell=powershell', () => {
const cmd = buildHookCommand('C:/Users/me/.claude', 'gsd-check-update.js', {
platform: 'win32',
runtime: 'claude',
hookShell: 'powershell',
});
assert.ok(cmd, 'buildHookCommand should return a command');
assert.ok(cmd.startsWith('& '), `PowerShell hook command must start with '& ': ${cmd}`);
});
test('buildHookCommand omits & prefix when hookShell=bash (regression lock)', () => {
const cmd = buildHookCommand('C:/Users/me/.claude', 'gsd-check-update.js', {
platform: 'win32',
runtime: 'claude',
hookShell: 'bash',
});
assert.ok(cmd, 'buildHookCommand should return a command');
assert.equal(cmd.startsWith('& '), false, `Git-Bash hook command must NOT start with '& ': ${cmd}`);
});
});
describe('bug #3413: installer hook surfaces consume runtime-aware projection', () => {
test('buildHookCommand emits shell-neutral Claude hook command on Windows', () => {
const cmd = buildHookCommand('C:/Users/me/.claude', 'gsd-check-update.js', {
platform: 'win32',
runtime: 'claude',
});
assert.equal(cmd.startsWith('& '), false, `Claude hook command must not use PowerShell prefix: ${cmd}`);
});
test('rewriteLegacyManagedNodeHookCommands removes stale PowerShell prefix for Claude on Windows', () => {
const settings = {
hooks: {
SessionStart: [{
hooks: [{ type: 'command', command: '& "/usr/local/bin/node" "C:/Users/me/.claude/hooks/gsd-check-update.js"' }],
}],
},
};
const changed = rewriteLegacyManagedNodeHookCommands(settings, '"/usr/local/bin/node"', {
platform: 'win32',
runtime: 'claude',
});
assert.equal(changed, true);
assert.equal(
settings.hooks.SessionStart[0].hooks[0].command,
'"/usr/local/bin/node" "C:/Users/me/.claude/hooks/gsd-check-update.js"',
);
});
});
describe('bug #3439: shell projection module owns managed-hook policy and legacy rewrite projection', () => {
test('isManagedHookBasename is surface-aware', () => {
assert.equal(isManagedHookBasename('/x/hooks/gsd-check-update.js', { surface: 'settings-json' }), true);
assert.equal(isManagedHookBasename('/x/hooks/gsd-statusline.js', { surface: 'settings-json' }), true);
assert.equal(isManagedHookBasename('/x/hooks/gsd-statusline.js', { surface: 'codex-toml' }), false);
assert.equal(isManagedHookBasename('/x/hooks/custom-hook.js', { surface: 'settings-json' }), false);
});
test('projectLegacySettingsHookCommand preserves non-Windows script token shape', () => {
const command = projectLegacySettingsHookCommand({
absoluteRunner: '"/usr/local/bin/node"',
scriptPath: '/x/hooks/gsd-statusline.js',
scriptToken: "'/x/hooks/gsd-statusline.js'",
platform: 'linux',
runtime: 'claude',
});
assert.equal(command, `"/usr/local/bin/node" '/x/hooks/gsd-statusline.js'`);
});
test('projectLegacySettingsHookCommand normalizes Windows managed paths and runtime wrapper policy', () => {
// #1928: gemini runtime removed — the PowerShell call-operator seam is now
// inert for every runtime (including the former 'gemini' string and its
// Gemini-backend successor 'antigravity'). No `& ` prefix is ever added.
const command = projectLegacySettingsHookCommand({
absoluteRunner: '"C:/nvm4w/nodejs/node.exe"',
scriptPath: 'C:\\Users\\me\\.gemini\\hooks\\gsd-prompt-guard.js',
scriptToken: "'C:\\Users\\me\\.gemini\\hooks\\gsd-prompt-guard.js'",
platform: 'win32',
runtime: 'antigravity',
});
assert.equal(command, '"C:/nvm4w/nodejs/node.exe" "C:/Users/me/.gemini/hooks/gsd-prompt-guard.js"');
});
test('projectLocalHookPrefix centralizes runtime-specific project-dir interpolation policy', () => {
// #2096: 'raw' hookPathStyle (descriptor-driven) is what produces the
// bare-dirName behavior now — the function no longer branches on the
// 'antigravity' runtime name itself.
assert.equal(
projectLocalHookPrefix({ runtime: 'antigravity', dirName: '.agents', hookPathStyle: 'raw' }),
'.agents',
);
assert.equal(
projectLocalHookPrefix({ runtime: 'claude', dirName: '.claude' }),
'"$CLAUDE_PROJECT_DIR"/.claude',
);
// #1928: gemini runtime removed — an unrecognized runtime string falls
// through to the default $CLAUDE_PROJECT_DIR-anchored prefix.
assert.equal(
projectLocalHookPrefix({ runtime: 'gemini', dirName: '.gemini' }),
'"$CLAUDE_PROJECT_DIR"/.gemini',
);
});
test('projectPortableHookBaseDir centralizes $HOME interpolation policy', () => {
assert.equal(
projectPortableHookBaseDir({
configDir: '/Users/me/.claude',
homeDir: '/Users/me',
}),
'$HOME/.claude',
);
assert.equal(
projectPortableHookBaseDir({
configDir: 'C:\\Users\\me\\.claude',
homeDir: 'C:\\Users\\me',
}),
'$HOME/.claude',
);
assert.equal(
projectPortableHookBaseDir({
configDir: '/opt/custom/.claude',
homeDir: '/Users/me',
}),
'/opt/custom/.claude',
);
});
test('isManagedHookCommand classifies managed settings hooks and leaves user commands untouched', () => {
assert.equal(
isManagedHookCommand('"/usr/local/bin/node" "/Users/me/.claude/hooks/gsd-statusline.js"', {
surface: 'settings-json',
}),
true,
);
assert.equal(
isManagedHookCommand('"C:/Program Files/Git/bin/bash.exe" "C:/Users/me/.claude/hooks/gsd-session-state.sh"', {
surface: 'settings-json',
}),
true,
);
assert.equal(
isManagedHookCommand('bash /Users/me/.claude/hooks/custom-lint.sh', {
surface: 'settings-json',
}),
false,
);
});
test('isManagedHookCommand supports codex surfaces and optional legacy alias matching', () => {
const command = '"/usr/local/bin/node" "/Users/me/.codex/hooks/gsd-check-update.js"';
assert.equal(
isManagedHookCommand(command, {
surface: 'codex-toml',
}),
true,
);
assert.equal(
isManagedHookCommand('"/usr/local/bin/node" "/Users/me/.codex/hooks/gsd-update-check.js"', {
surface: 'codex-toml',
}),
false,
);
assert.equal(
isManagedHookCommand('"/usr/local/bin/node" "/Users/me/.codex/hooks/gsd-update-check.js"', {
surface: 'codex-toml',
includeLegacyAliases: true,
}),
true,
);
});
});
describe('#1693 regression: Windows legacy-node rewrite must not double-quote a "$CLAUDE_PROJECT_DIR"-anchored local hook path', () => {
const winRunner = '"C:/Program Files/nodejs/node.exe"';
// WHY: a local-install hook path already carries a `"$CLAUDE_PROJECT_DIR"`
// anchored prefix (only the variable quoted, rest bare). On Windows the legacy
// rewrite previously JSON.stringify'd the whole token, yielding
// `"\"$CLAUDE_PROJECT_DIR\"/..."`. node then received an argument starting with
// a literal `"`, treated it as relative, and died with MODULE_NOT_FOUND —
// breaking every node managed hook at once (self-locking deadlock).
test('projectLegacySettingsHookCommand emits the anchored path verbatim, not re-quoted', () => {
const anchored = '"$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-context-monitor.js';
const command = projectLegacySettingsHookCommand({
absoluteRunner: winRunner,
scriptPath: anchored,
scriptToken: anchored,
platform: 'win32',
runtime: 'claude',
});
assert.equal(
command,
'"C:/Program Files/nodejs/node.exe" "$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-context-monitor.js',
);
assert.ok(!command.includes('\\"'), 'must not contain escaped double-quotes');
});
// WHY: the fix must be surgical — a BARE absolute Windows path (no anchored
// prefix) can contain spaces ("Program Files") and still REQUIRES quoting.
test('projectLegacySettingsHookCommand still quotes a bare absolute Windows path', () => {
const abs = 'C:/Program Files App/.claude/hooks/gsd-context-monitor.js';
const command = projectLegacySettingsHookCommand({
absoluteRunner: winRunner,
scriptPath: abs,
scriptToken: JSON.stringify(abs),
platform: 'win32',
runtime: 'claude',
});
assert.equal(
command,
'"C:/Program Files/nodejs/node.exe" "C:/Program Files App/.claude/hooks/gsd-context-monitor.js"',
);
});
// WHY: the anchored short-circuit is scoped to win32. On POSIX the rewrite
// already preserved the caller's original `scriptToken` and never had the
// double-quote bug, so that behavior must be left byte-identical. scriptPath
// is anchored but scriptToken is a DISTINCT single-quoted value: if the
// win32 gate were removed, the anchored short-circuit would emit scriptPath
// and this assertion would fail — that is what pins the gate.
test('projectLegacySettingsHookCommand preserves the original scriptToken for anchored paths on POSIX', () => {
const command = projectLegacySettingsHookCommand({
absoluteRunner: '"/usr/local/bin/node"',
scriptPath: '"$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-statusline.js',
scriptToken: "'/x/hooks/gsd-statusline.js'",
platform: 'linux',
runtime: 'claude',
});
assert.equal(command, `"/usr/local/bin/node" '/x/hooks/gsd-statusline.js'`);
});
// WHY: end-to-end through the installer rewrite — the actual #2979 path that
// ran during the user's 1.5.0 -> 1.6.0 local update. Managed node hooks get
// the absolute runner + clean anchored path; a non-node-prefixed managed .sh
// hook (already correct) is left untouched.
test('rewriteLegacyManagedNodeHookCommands produces clean anchored node commands on Windows', () => {
const settings = {
hooks: {
PostToolUse: [
{
hooks: [
{ command: 'node "$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-context-monitor.js' },
],
},
],
},
};
const changed = rewriteLegacyManagedNodeHookCommands(settings, winRunner, {
platform: 'win32',
runtime: 'claude',
});
assert.equal(changed, true);
const rewritten = settings.hooks.PostToolUse[0].hooks[0].command;
assert.equal(
rewritten,
'"C:/Program Files/nodejs/node.exe" "$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-context-monitor.js',
);
assert.ok(!rewritten.includes('\\"'), 'rewritten command must not contain escaped double-quotes');
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-3441-path-action-projection.test.cjs — consolidation epic #1969 (B3 #1972)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-3441-path-action-projection (consolidation epic #1969 B3 #1972)", () => {
'use strict';
process.env.GSD_TEST_MODE = '1';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const fs = require('node:fs');
const os = require('node:os');
const projection = require(path.join(
__dirname,
'..',
'gsd-core',
'bin',
'lib',
'shell-command-projection.cjs',
));
const install = require(path.join(__dirname, '..', 'bin', 'install.js'));
const { withIsolatedProcessState, cleanup } = require('./helpers.cjs');
function createTempHome() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-home-3441-'));
}
describe('bug #3441: PATH guidance is projected from typed shell action IR', () => {
test('projection module exports PATH action projection helper', () => {
assert.equal(typeof projection.projectPathActionProjection, 'function');
});
// (formatSdkPathDiagnostic removed with the gsd-sdk shim, #191 — the PATH
// action projection it wrapped is still covered by the tests below.)
test('persistent PATH export guidance is projected via the same seam', () => {
const posix = projection.projectPathActionProjection({
mode: 'persist',
targetDir: '/tmp/with quote',
platform: 'linux',
});
assert.ok(Array.isArray(posix.shellActions));
assert.equal(posix.shellActions.length, 3);
assert.equal(posix.shellActions[0].label, 'zsh');
assert.equal(posix.shellActions[1].label, 'bash');
assert.equal(posix.shellActions[2].label, 'fish');
assert.ok(posix.shellActions[0].command.includes('~/.zshrc'));
assert.ok(posix.shellActions[1].command.includes('~/.bashrc'));
// #323: fish gets a fish-native fish_add_path suggestion, not `export`.
assert.ok(posix.shellActions[2].command.startsWith('fish_add_path '));
assert.ok(!posix.shellActions[2].command.includes('export'));
});
// #323 (ported from the closed #721): the fish suggestion is POSIX-only.
// On win32 the persist branch projects PowerShell / cmd.exe / Git Bash —
// no fish action — locking the POSIX-only contract.
test('no fish action is projected on win32', () => {
const win = projection.projectPathActionProjection({
mode: 'persist',
targetDir: 'C:\\Users\\me\\AppData\\npm',
platform: 'win32',
});
assert.ok(Array.isArray(win.shellActions));
assert.equal(
win.shellActions.some((a) => a.shell === 'fish' || a.label === 'fish'),
false,
'win32 persist projection must not include a fish action',
);
assert.deepEqual(
win.shellActions.map((a) => a.label),
['PowerShell', 'cmd.exe', 'Git Bash'],
);
});
test('POSIX repair mode escapes double-quoted shell metacharacters', () => {
const projected = projection.projectPathActionProjection({
mode: 'repair',
targetDir: '/tmp/qa\\"$HOME`tick',
platform: 'linux',
});
assert.equal(projected.shellActions.length, 1);
assert.equal(
projected.shellActions[0].command,
'export PATH="/tmp/qa\\\\\\"\\$HOME\\`tick:$PATH"',
);
});
test('POSIX persist mode escapes single quotes for rc-file echo commands', () => {
const projected = projection.projectPathActionProjection({
mode: 'persist',
targetDir: "/tmp/O'Neil/bin",
platform: 'linux',
});
assert.equal(projected.shellActions[0].command.includes("/tmp/O'\\''Neil/bin"), true);
assert.equal(projected.shellActions[1].command.includes("/tmp/O'\\''Neil/bin"), true);
// #323: fish entry single-quotes the dir with the same POSIX literal
// escaping (`'\''` is also a valid escaped quote in fish unquoted context).
assert.equal(projected.shellActions[2].command, "fish_add_path '/tmp/O'\\''Neil/bin'");
});
test('maybeSuggestPathExport renders commands projected by path-action seam', () => {
const home = createTempHome();
try {
withIsolatedProcessState(() => {
const globalBin = path.join(home, '.npm-global', 'bin');
fs.mkdirSync(globalBin, { recursive: true });
fs.writeFileSync(path.join(home, '.zshrc'), 'export PATH="$HOME/.cargo/bin:$PATH"\n');
process.env.PATH = '';
const expected = projection.projectPathActionProjection({
mode: 'persist',
targetDir: globalBin,
platform: process.platform,
});
const logs = [];
const originalLog = console.log;
console.log = (...args) => logs.push(args.join(' '));
try {
install.maybeSuggestPathExport(globalBin, home);
} finally {
console.log = originalLog;
}
const joined = logs.join('\n');
for (const action of expected.shellActions) {
assert.ok(
joined.includes(action.command),
`expected installer output to include projected command: ${action.command}\nOutput:\n${joined}`,
);
}
});
} finally {
cleanup(home);
}
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-580-local-sh-hook-bash-wrapper.test.cjs — consolidation epic #1969 (B3 #1972)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-580-local-sh-hook-bash-wrapper (consolidation epic #1969 B3 #1972)", () => {
'use strict';
/**
* Regression test for bug #580.
*
* LOCAL install under Claude Code on Windows: managed `.sh` hooks were emitted
* wrapped with an absolute `bash.exe` path via the `localShellCmd` arrow.
* Since Claude Code runs hook command strings INSIDE Git Bash, bash tries to
* exec bash → "cannot execute binary file".
*
* The GLOBAL path (`buildHookCommand`) already guarded win32+claude+.sh (#166).
* The LOCAL path (`localShellCmd`) did not. Fix: add `buildLocalShellHookCommand`
* and `shellHookOmitsBashRunner` to shell-command-projection.cjs, and use
* `buildLocalShellHookCommand` from install.js local-install path.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const projection = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'shell-command-projection.cjs'));
const { buildLocalShellHookCommand, shellHookOmitsBashRunner, projectLocalHookPrefix } = projection;
describe('bug #580: local .sh hooks on Claude/Windows must NOT wrap with bash.exe', () => {
test('local .sh hook on Claude/Windows omits the bash.exe wrapper (#580)', () => {
const localPrefix = projectLocalHookPrefix({ runtime: 'claude', dirName: '.claude' });
const result = buildLocalShellHookCommand({
localPrefix,
hookFile: 'gsd-session-state.sh',
bashRunner: '"C:/Program Files/Git/bin/bash.exe"',
runtime: 'claude',
platform: 'win32',
});
assert.equal(result, '"$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-session-state.sh');
assert.ok(!result.includes('bash.exe'), `result must not contain bash.exe, got: ${result}`);
});
test('local .sh hook on Claude/Windows still emits script path when bash.exe is unresolved', () => {
const localPrefix = projectLocalHookPrefix({ runtime: 'claude', dirName: '.claude' });
const result = buildLocalShellHookCommand({
localPrefix,
hookFile: 'gsd-session-state.sh',
bashRunner: null,
runtime: 'claude',
platform: 'win32',
});
assert.equal(result, '"$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-session-state.sh');
});
test('local .sh hook on POSIX keeps the bash runner', () => {
const localPrefix = projectLocalHookPrefix({ runtime: 'claude', dirName: '.claude' });
const result = buildLocalShellHookCommand({
localPrefix,
hookFile: 'gsd-session-state.sh',
bashRunner: 'bash',
runtime: 'claude',
platform: 'linux',
});
assert.equal(result, 'bash "$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-session-state.sh');
});
test('local .sh hook on Windows non-Claude runtime keeps the bash runner', () => {
const localPrefix = projectLocalHookPrefix({ runtime: 'codex', dirName: '.claude' });
const result = buildLocalShellHookCommand({
localPrefix,
hookFile: 'gsd-session-state.sh',
bashRunner: '"C:/Program Files/Git/bin/bash.exe"',
runtime: 'codex',
platform: 'win32',
});
assert.ok(result.includes('bash.exe'), `result must contain bash.exe, got: ${result}`);
assert.ok(result.startsWith('"C:/Program Files/Git/bin/bash.exe"'), `result must start with bash.exe token, got: ${result}`);
});
test('all four managed local .sh hooks drop the wrapper on Claude/Windows', () => {
const localPrefix = projectLocalHookPrefix({ runtime: 'claude', dirName: '.claude' });
const hooks = [
'gsd-session-state.sh',
'gsd-validate-commit.sh',
'gsd-graphify-update.sh',
'gsd-phase-boundary.sh',
];
for (const f of hooks) {
const result = buildLocalShellHookCommand({
localPrefix,
hookFile: f,
bashRunner: '"C:/Program Files/Git/bin/bash.exe"',
runtime: 'claude',
platform: 'win32',
});
assert.equal(
result,
`"$CLAUDE_PROJECT_DIR"/.claude/hooks/${f}`,
`expected script-only path for ${f}, got: ${result}`,
);
assert.ok(!result.includes('bash.exe'), `result for ${f} must not contain bash.exe, got: ${result}`);
}
});
test('shellHookOmitsBashRunner truth table', () => {
// true only for win32 + claude + isShellHook:true
assert.equal(shellHookOmitsBashRunner({ platform: 'win32', runtime: 'claude', isShellHook: true }), true);
// false for win32 + claude + isShellHook:false
assert.equal(shellHookOmitsBashRunner({ platform: 'win32', runtime: 'claude', isShellHook: false }), false);
// false for win32 + codex + isShellHook:true
assert.equal(shellHookOmitsBashRunner({ platform: 'win32', runtime: 'codex', isShellHook: true }), false);
// false for linux + claude + isShellHook:true
assert.equal(shellHookOmitsBashRunner({ platform: 'linux', runtime: 'claude', isShellHook: true }), false);
// false for default args (no win32, no claude)
assert.equal(shellHookOmitsBashRunner(), false);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/feat-3595-fs-fault-injection-atomic-write.test.cjs — consolidation epic #1969 (B3 #1972)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:feat-3595-fs-fault-injection-atomic-write (consolidation epic #1969 B3 #1972)", () => {
/**
* Filesystem fault-injection coverage for the canonical atomic-write
* seam (#3595).
*
* `platformWriteSync` in `gsd-core/bin/lib/shell-command-projection.cjs`
* is the shared seam every config/state/generated-artifact writer in
* the CJS layer routes through. Its contract:
*
* 1. mkdirSync(dirname, { recursive: true }) — ensure parent exists.
* 2. writeFileSync(<tmpPath>, content) — write to a sibling tmpfile
* named `<filePath>.tmp.<pid>`.
* 3. renameSync(<tmpPath>, filePath) — atomic publish.
* 4. On any error in steps 2-3: unlinkSync(<tmpPath>) (best-effort)
* then writeFileSync(filePath, content) directly as a fallback.
*
* Per CONTRIBUTING.md §"QA Matrix Requirements / Filesystem writes and
* installers" the tests below use `mock.method()` against the real `fs`
* seam to drive each fault mode, restore mocks with `t.after()`, and
* assert on observable post-conditions (file existence, content,
* presence/absence of orphan tmp files, propagated error code) — not
* on prose.
*
* Pre-existing behavior gaps surfaced and PINNED (not fixed in this
* PR — #3595 is test coverage, fixes belong in separate issues):
*
* - The "fall back to direct write" branch silently swallows the
* ORIGINAL error from the tmp+rename path. If the fallback ALSO
* fails, the operator only sees the fallback's error — not the
* original cause. Tests document this.
* - On EACCES against the parent directory mkdir, the error
* escapes (no try/catch around mkdirSync). Pinned.
*/
'use strict';
const { test, mock } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const {
platformWriteSync,
platformEnsureDir,
} = require('../gsd-core/bin/lib/shell-command-projection.cjs');
/**
* Create a fresh real-fs scratch dir per test so no two faults share
* state. Returns the directory; caller must clean up.
*/
const { createTempDir, cleanup } = require('./helpers.cjs');
const mkScratch = (name) => createTempDir(`fs-fault-${name}-`);
/**
* Enumerate orphan tmp files left behind by platformWriteSync. The
* tmp shape is `<filename>.tmp.<pid>`; we match that pattern strictly
* so a test that happens to write a real `*.tmp.123` doesn't get a
* false positive.
*/
function orphanTmpFiles(dir) {
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir).filter((n) => /\.tmp\.\d+$/.test(n));
}
// ─── Happy path baseline ────────────────────────────────────────────────────
test('platformWriteSync happy path writes content atomically (baseline for fault tests)', (t) => {
const dir = mkScratch('happy');
t.after(() => cleanup(dir));
const file = path.join(dir, 'config.json');
platformWriteSync(file, '{"k":"v"}\n');
assert.equal(fs.readFileSync(file, 'utf-8'), '{"k":"v"}\n');
assert.deepEqual(orphanTmpFiles(dir), [], 'happy path must leave no orphan tmp file');
});
// ─── Rename failure → falls back to direct write ────────────────────────────
test('platformWriteSync recovers when renameSync fails (EXDEV cross-device fallback)', (t) => {
const dir = mkScratch('exdev');
t.after(() => cleanup(dir));
const file = path.join(dir, 'config.json');
// Simulate rename failing once (e.g. cross-device move on a CI runner
// with overlayfs). The fallback path must write the content directly.
let renameCalls = 0;
const renameMock = mock.method(fs, 'renameSync', (_src, _dest) => {
renameCalls++;
const err = new Error('EXDEV: cross-device link not permitted');
err.code = 'EXDEV';
throw err;
});
t.after(() => renameMock.mock.restore());
platformWriteSync(file, 'fallback content\n');
// File exists and has the new content — fallback wrote it directly.
assert.equal(fs.readFileSync(file, 'utf-8'), 'fallback content\n');
assert.equal(renameCalls, 1, 'renameSync was called exactly once before falling back');
// The tmp file was unlinked by the fallback path's best-effort cleanup.
assert.deepEqual(orphanTmpFiles(dir), [], 'tmp file must be cleaned up after rename failure');
});
// ─── #1540: transient Windows lock (EPERM/EBUSY/EACCES) is RETRIED, never
// fallen back to a non-atomic truncating write ───────────────────
test('platformWriteSync retries a transient EPERM rename and publishes atomically (#1540)', (t) => {
const dir = mkScratch('eperm-transient');
t.after(() => cleanup(dir));
const file = path.join(dir, 'STATE.md');
// A reader briefly holds the target open → rename throws EPERM once, then clears.
let renameCalls = 0;
const originalRename = fs.renameSync;
const renameMock = mock.method(fs, 'renameSync', (src, dest) => {
renameCalls++;
if (renameCalls === 1) {
const err = new Error('EPERM: a reader holds the target open');
err.code = 'EPERM';
throw err;
}
return originalRename.call(fs, src, dest);
});
t.after(() => renameMock.mock.restore());
platformWriteSync(file, 'published\n');
assert.equal(renameCalls, 2, 'rename retried after a transient EPERM (not a single-shot non-atomic fallback)');
assert.equal(fs.statSync(file).isFile(), true);
assert.ok(fs.statSync(file).size > 0, 'target published, not truncated');
assert.deepEqual(orphanTmpFiles(dir), [], 'atomic publish leaves no tmp orphan');
});
test('platformWriteSync surfaces a PERSISTENT EPERM instead of truncating a concurrent reader (#1540)', (t) => {
const dir = mkScratch('eperm-persistent');
t.after(() => cleanup(dir));
const file = path.join(dir, 'STATE.md');
// A reader is mid-read on `file` with known content. The old blanket fallback
// would non-atomically writeFileSync over it — truncating the reader. The fix
// must surface the error and leave the existing file byte-for-byte intact.
fs.writeFileSync(file, 'OLD CONTENT A READER IS MID-READ ON\n');
const sizeBefore = fs.statSync(file).size;
let renameCalls = 0;
const renameMock = mock.method(fs, 'renameSync', () => {
renameCalls++;
const err = new Error('EPERM: reader holds the target open');
err.code = 'EPERM';
throw err;
});
t.after(() => renameMock.mock.restore());
let caught;
try {
platformWriteSync(file, 'NEW CONTENT\n');
} catch (err) {
caught = err;
}
assert.ok(caught, 'a persistent rename lock must surface as an error, not a silent truncating write');
assert.equal(caught.code, 'EPERM');
assert.equal(renameCalls, 3, 'rename retried up to the bounded limit before surfacing');
// Negative proof: the concurrent reader's file was NOT truncated/overwritten.
assert.equal(fs.statSync(file).size, sizeBefore, 'target left intact — no non-atomic write happened');
assert.equal(fs.readFileSync(file, 'utf-8'), 'OLD CONTENT A READER IS MID-READ ON\n');
assert.deepEqual(orphanTmpFiles(dir), [], 'tmp cleaned up after surfacing the error');
});
// ─── Tmp write failure → falls back to direct write ─────────────────────────
test('platformWriteSync falls back when initial tmp writeFileSync fails (ENOSPC)', (t) => {
const dir = mkScratch('enospc');
t.after(() => cleanup(dir));
const file = path.join(dir, 'config.json');
// Make the FIRST writeFileSync (to .tmp.<pid>) fail with ENOSPC. The
// SECOND writeFileSync (the fallback, direct to filePath) succeeds.
let writeCalls = 0;
const realWrite = fs.writeFileSync;
const writeMock = mock.method(fs, 'writeFileSync', function (target, data, opts) {
writeCalls++;
if (writeCalls === 1) {
// First call is to the tmp path.
assert.match(String(target), /\.tmp\.\d+$/, 'first write must be to the tmp path');
const err = new Error('ENOSPC: no space left on device');
err.code = 'ENOSPC';
throw err;
}
// Second call is the fallback to the real target.
assert.equal(target, file, 'fallback write must target the original file path');
return realWrite.call(fs, target, data, opts);
});
t.after(() => writeMock.mock.restore());
platformWriteSync(file, 'recovered\n');
assert.equal(writeCalls, 2, 'expected exactly 2 writeFileSync calls (tmp fail + fallback)');
assert.equal(fs.readFileSync(file, 'utf-8'), 'recovered\n');
// The fallback path tries unlinkSync on the tmp; the tmp never
// existed (its write failed), so unlink throws ENOENT and is
// swallowed by the inner catch. Either way: no orphan.
assert.deepEqual(orphanTmpFiles(dir), []);
});
// ─── Both attempts fail → error propagates (pinned current behavior) ────────
test('platformWriteSync propagates the FALLBACK error when both tmp and fallback writes fail', (t) => {
const dir = mkScratch('double-fail');
t.after(() => cleanup(dir));
const file = path.join(dir, 'config.json');
let writeCalls = 0;
const writeMock = mock.method(fs, 'writeFileSync', function () {
writeCalls++;
const err = new Error(
writeCalls === 1
? 'ENOSPC: original failure on tmp write'
: 'EACCES: permission denied on fallback write',
);
err.code = writeCalls === 1 ? 'ENOSPC' : 'EACCES';
throw err;
});
t.after(() => writeMock.mock.restore());
// The current implementation does NOT chain the original cause; the
// fallback's error is what surfaces. This test pins that behavior so a
// future "preserve original error in .cause" fix is a visible change
// (open follow-up).
let caught;
try {
platformWriteSync(file, 'wont-write\n');
} catch (err) {
caught = err;
}
assert.ok(caught, 'double-failure must throw');
assert.equal(caught.code, 'EACCES', 'currently the fallback error wins (open: should chain via .cause)');
// The original file was never created.
assert.equal(fs.existsSync(file), false);
// No orphan tmp left because the tmp write failed before any file was created.
assert.deepEqual(orphanTmpFiles(dir), []);
});
// ─── mkdirSync failure → escapes immediately (no try/catch upstream) ────────
test('platformWriteSync propagates mkdirSync failure unchanged (no swallowed parent-dir errors)', (t) => {
const dir = mkScratch('mkdir-fail');
t.after(() => cleanup(dir));
const file = path.join(dir, 'deep', 'nested', 'config.json');
const mkdirMock = mock.method(fs, 'mkdirSync', () => {
const err = new Error('EACCES: permission denied creating directory');
err.code = 'EACCES';
throw err;
});
t.after(() => mkdirMock.mock.restore());
let caught;
try {
platformWriteSync(file, 'never-reached\n');
} catch (err) {
caught = err;
}
assert.ok(caught, 'mkdir failure must propagate');
assert.equal(caught.code, 'EACCES', 'mkdir error code must be preserved');
// No partial write happened.
assert.equal(fs.existsSync(file), false);
});
// ─── Target path is a directory (writing OVER a dir is undefined) ──────────
test('platformWriteSync against a target path that is an existing directory fails cleanly', (t) => {
const dir = mkScratch('target-is-dir');
t.after(() => cleanup(dir));
const file = path.join(dir, 'collides');
// Pre-create the target AS a directory so the rename step would
// collide with a directory at the destination.
fs.mkdirSync(file);
let caught;
try {
platformWriteSync(file, 'shouldnt-overwrite-dir\n');
} catch (err) {
caught = err;
}
assert.ok(caught, 'writing over an existing directory must fail');
// We don't pin the exact code (Node varies: EISDIR on rename, EPERM on
// some platforms). We pin: a real error code (string starting with E)
// surfaces, AND the directory was not replaced by a file.
assert.equal(typeof caught.code, 'string');
assert.match(caught.code, /^E[A-Z]+$/, `expected an errno-style code, got ${caught.code}`);
assert.equal(fs.statSync(file).isDirectory(), true, 'pre-existing directory must remain a directory');
});
// ─── Path with spaces, unicode, newline characters ─────────────────────────
test('platformWriteSync handles paths with spaces, unicode, and newline characters', (t) => {
const dir = mkScratch('weird-path');
t.after(() => cleanup(dir));
const cases = [
'has spaces in name.json',
'unicode-日本語-name.json',
];
if (process.platform !== 'win32') {
// Tab (0x09) and newline (0x0A) in filenames are POSIX-valid but
// Windows-illegal (NTFS forbids control characters 0x00–0x1F). Append
// both only on POSIX so cross-platform CI stays green.
cases.push('with\ttab.json');
cases.push('with\nnewline.json');
}
for (const name of cases) {
const file = path.join(dir, name);
platformWriteSync(file, `payload for ${name}\n`);
assert.equal(
fs.readFileSync(file, 'utf-8'),
`payload for ${name}\n`,
`roundtrip failed for path "${JSON.stringify(name)}"`,
);
}
assert.deepEqual(orphanTmpFiles(dir), [], 'no tmp orphans across the corpus');
});
// ─── Orphan-tmp cleanup invariant ──────────────────────────────────────────
test('platformWriteSync never leaks a tmp file after a successful happy-path write', (t) => {
const dir = mkScratch('no-orphan-happy');
t.after(() => cleanup(dir));
for (let i = 0; i < 25; i++) {
platformWriteSync(path.join(dir, `f-${i}.json`), `{"i":${i}}\n`);
}
// 25 real files, 0 tmp orphans.
const entries = fs.readdirSync(dir);
const tmpCount = entries.filter((n) => /\.tmp\.\d+$/.test(n)).length;
const realCount = entries.length - tmpCount;
assert.equal(realCount, 25);
assert.equal(tmpCount, 0);
});
// ─── platformEnsureDir is idempotent and chains errors ─────────────────────
test('platformEnsureDir is idempotent on an existing directory', (t) => {
const dir = mkScratch('ensure-idem');
t.after(() => cleanup(dir));
const target = path.join(dir, 'a', 'b', 'c');
// First call creates; subsequent calls must not throw EEXIST.
platformEnsureDir(target);
assert.equal(fs.statSync(target).isDirectory(), true);
// Repeat.
platformEnsureDir(target);
platformEnsureDir(target);
assert.equal(fs.statSync(target).isDirectory(), true);
});
test('platformEnsureDir propagates EACCES when parent dir is unwritable', (t) => {
const dir = mkScratch('ensure-fail');
t.after(() => cleanup(dir));
const mkdirMock = mock.method(fs, 'mkdirSync', () => {
const err = new Error('EACCES: permission denied');
err.code = 'EACCES';
throw err;
});
t.after(() => mkdirMock.mock.restore());
let caught;
try {
platformEnsureDir(path.join(dir, 'cannot-create'));
} catch (err) {
caught = err;
}
assert.ok(caught);
assert.equal(caught.code, 'EACCES');
});
// ─── Symlink-following / escape behavior ────────────────────────────────────
test('platformWriteSync REPLACES a symlink with a regular file rather than following it (safe behavior)', (t) => {
// Security-relevant invariant: if the destination path is a symlink
// pointing somewhere the user did not intend the writer to touch
// (e.g. an attacker-planted symlink in `.planning/` pointing at
// `~/.ssh/authorized_keys`), the writer must NOT follow it and
// clobber the target. The rename-based atomic-write pattern delivers
// this property: `renameSync(tmp, symlinkPath)` replaces the symlink
// entry in the parent directory with the regular file at `tmp`.
// After the call:
// - `linkPath` is a regular file with the new content.
// - The original `realTarget` is UNTOUCHED.
// This test pins that property so a future refactor (e.g. switching
// to `fs.writeFileSync(linkPath, ...)` which follows symlinks) is a
// visible regression.
if (process.platform === 'win32') {
t.skip('symlinks on Win32 need admin');
return;
}
const dir = mkScratch('symlink-replace');
t.after(() => cleanup(dir));
const realTarget = path.join(dir, 'real-target.json');
fs.writeFileSync(realTarget, 'original — must not be touched\n');
const linkPath = path.join(dir, 'link.json');
fs.symlinkSync(realTarget, linkPath);
platformWriteSync(linkPath, 'new content\n');
// The real target is UNTOUCHED — the safety property.
assert.equal(
fs.readFileSync(realTarget, 'utf-8'),
'original — must not be touched\n',
'symlink target must be preserved when writer writes "through" the link',
);
// The link entry is now a regular file with the new content.
const stat = fs.lstatSync(linkPath);
assert.equal(stat.isSymbolicLink(), false, 'symlink entry replaced by a regular file (atomic rename semantics)');
assert.equal(stat.isFile(), true);
assert.equal(fs.readFileSync(linkPath, 'utf-8'), 'new content\n');
});
test('platformWriteSync against a broken symlink replaces it with the intended file', (t) => {
if (process.platform === 'win32') {
t.skip('symlinks on Win32 need admin');
return;
}
const dir = mkScratch('symlink-broken');
t.after(() => cleanup(dir));
const link = path.join(dir, 'dangling.json');
fs.symlinkSync(path.join(dir, 'does-not-exist'), link);
assert.equal(fs.lstatSync(link).isSymbolicLink(), true, 'pre-check: link is dangling');
platformWriteSync(link, 'real content\n');
assert.equal(fs.readFileSync(link, 'utf-8'), 'real content\n');
assert.equal(fs.lstatSync(link).isSymbolicLink(), false, 'broken link replaced with regular file');
});
// ─── Concurrent-write collision ────────────────────────────────────────────
test('platformWriteSync survives a concurrent collision on the same target path', (t) => {
// Two consecutive writes to the same path with DIFFERENT contents,
// separated by a setImmediate boundary so the second can interleave
// a mock-injected error mid-flight. The contract pinned here: after
// both writes complete, the file is parseable and contains ONE of the
// two contents — never a half-written corrupt blob.
//
// (True parallel writes from separate processes are out of scope —
// the writer is sync. This exercises mid-flight error recovery, which
// is the same concurrency hazard at a lower granularity.)
const dir = mkScratch('concurrent');
t.after(() => cleanup(dir));
const file = path.join(dir, 'race.json');
// First write completes normally.
platformWriteSync(file, '{"writer":"first"}\n');
// Second write: inject a transient EBUSY on the first rename attempt,
// then succeed on the bounded retry (#1540). Capture the real renameSync
// BEFORE installing the mock so the retry attempt delegates to the real
// implementation. The previous form referenced a non-existent
// `fs.renameSync.wrapped` property — that branch would silently no-op
// instead of delegating.
let renameCalls = 0;
const originalRename = fs.renameSync;
const renameMock = mock.method(fs, 'renameSync', (src, dest) => {
renameCalls++;
if (renameCalls === 1) {
const err = new Error('EBUSY: file is locked');
err.code = 'EBUSY';
throw err;
}
return originalRename.call(fs, src, dest);
});
t.after(() => renameMock.mock.restore());
platformWriteSync(file, '{"writer":"second"}\n');
// The bounded retry re-published the 'second' content atomically.
const final = fs.readFileSync(file, 'utf-8');
// Must be valid JSON — never a half-merged corruption.
assert.doesNotThrow(() => JSON.parse(final), 'file must remain parseable after the contested write');
// Must be the SECOND writer's content (it called platformWriteSync
// after the first, and the fallback completed).
assert.equal(final, '{"writer":"second"}\n');
assert.deepEqual(orphanTmpFiles(dir), [], 'no tmp orphans after the contested write');
});
});
}