Files
msd-core/tests/sh-hook-paths.test.cjs
Tom Boucher 682eaae3f0 enh(#2876): retire the dead and pass-through exports from bin/install.js (#3615)
* enh(#2876): retire the dead and pass-through exports from bin/install.js

The installer exported 197 names and had zero production consumers - every
non-test require of it repo-wide sits inside a comment. Its interface was
shaped by test access, not by callers.

Removes 9 dead exports and 61 pass-throughs, repointing their tests onto the
extracted modules' own interfaces. 197 down to 127.

Every count in the issue was wrong: 197 exports not 188, 9 dead not 12, 61
pass-throughs not 49, 44 test files not 42 - and the audit itself then missed
7 more consumer files. restoreUserArtifacts was on the dead list but ceased to
exist in phase 6, and two _GSD_EFFORT_MANIFEST_* names listed as dead are now
genuinely asserted, so acting on that list would have deleted live exports.

7 of the 9 dead names collide with an independent declaration that install.js
delegates TO. Each removal was justified by which declaration a reference
resolves to, never by whether the name appears somewhere.

Coverage parity was the gate rather than test greenness: per-file counts were
captured before any edit and diffed after. 44 of 45 files are byte-identical;
the single delta is one added assertion, not a loss.

The sweep for scattered require sites found two forms static grep misses -
require(VARIABLE) and multi-line require() - plus tests asserting that
install.js re-exports the SAME object, which now assert retirement instead.

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

* fix(#2876): close review findings — restore the duplicate-body guard, sweep orphaned code

Both review engines found real defects in the first cut.

The DEFECT.GENERATIVE-FIX single-owner guard from #1511 had been repointed
from a reference-identity check to install.X === undefined. Those are not
equivalent: the guard exists to catch a duplicate function body reintroduced
into install.js, and the replacement passes cleanly if that duplicate is used
internally and never exported. It now walks bin/install.js's real top-level
bindings, so it catches a duplicate under either shape, exported or not -
strictly stronger than the check it replaced. Proved by injecting a duplicate
and watching it go red.

That weakening survived the coverage-parity gate because the assertion count
never moved. The gate compares counts, so an assertion that changes meaning
rather than number is invisible to it.

Removing the exports had orphaned their wrapper bodies: 14 dead wrappers, 9
consts and 9 destructure entries, several pre-existing and found by the same
sweep. Dead code left in the file this phase exists to shrink.

Three more comments claimed re-exports this phase removed, and tests were
reading Cursor and Windsurf hook constants from install.js's local copy while
calling functions from the hooks surface - equal today, with nothing holding
them equal. The local consts now reference the owning module.

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

* chore(#2876): backfill changeset pr number

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

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 09:23:44 -04:00

221 lines
11 KiB
JavaScript

/**
* Regression tests for bugs #2045 and #2046
*
* #2046 (macOS/Linux): The three .sh hooks (gsd-validate-commit.sh,
* gsd-session-state.sh, gsd-phase-boundary.sh) were registered in
* settings.json with RELATIVE paths (bash .claude/hooks/...) for local
* installs, causing "No such file or directory" when Claude Code's cwd
* is not the project root.
*
* #2045 (Windows): The same three .sh hooks were registered WITHOUT quotes
* around the path, so usernames with spaces (e.g. C:/Users/First Last/)
* break bash invocation with a syntax error.
*
* Root cause: buildHookCommand() only handled .js files. The .sh hooks were
* built via manual string concatenation without quoting, and local installs
* used localPrefix (.claude/...) instead of the $CLAUDE_PROJECT_DIR-anchored
* form that .js local hooks use.
*
* Fix: extend buildHookCommand() to handle .sh files (uses 'bash' instead of
* 'node') so that all paths go through the same quoted-path construction.
*/
'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('path');
// buildHookCommand was extracted to gsd-core/bin/lib/runtime-hooks-surface.cjs
// (ADR-857 phase 5f-1). #2876 (epic #2866 Phase 7) retired install.js's
// pass-through re-export (zero production consumers) — import the module
// directly.
const { buildHookCommand } = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'runtime-hooks-surface.cjs'));
const SH_HOOKS = [
{ name: 'gsd-validate-commit.sh' },
{ name: 'gsd-session-state.sh' },
{ name: 'gsd-phase-boundary.sh' },
];
// Use a fixed configDir that is unambiguously absolute so the assertions below
// are not accidentally satisfied by a relative path in the output.
const TEST_CONFIG_DIR = '/test-home/.claude';
// Force a non-Windows platform so resolveBashRunner reliably returns 'bash'
// (Windows candidates need filesystem probing; platform:linux is hermetic).
const HOOK_OPTS = { platform: 'linux', runtime: 'claude' };
describe('bugs #2045 #2046: .sh hook paths must be absolute and quoted', () => {
// ── Test 1: buildHookCommand supports .sh files (BEHAVIORAL) ─────────────
describe('buildHookCommand', () => {
test('returns a bash command for .sh hookName', () => {
// Behavioral: call the exported function and assert on the returned string.
// buildHookCommand was extracted to runtime-hooks-surface.cjs; source-grep
// on install.js no longer works (the wrapper body just delegates).
assert.equal(typeof buildHookCommand, 'function',
'buildHookCommand must be exported from install.js');
const cmd = buildHookCommand(TEST_CONFIG_DIR, 'gsd-validate-commit.sh', HOOK_OPTS);
assert.ok(typeof cmd === 'string' && cmd.length > 0,
'buildHookCommand must return a non-empty string for .sh hooks');
assert.ok(
cmd.includes('bash'),
'buildHookCommand must use "bash" as the runner for .sh hooks. ' +
`Got: ${cmd}`
);
});
test('buildHookCommand produces bash runner for .sh and node runner for .js', () => {
assert.equal(typeof buildHookCommand, 'function',
'buildHookCommand must be exported from install.js');
// .sh hook must contain "bash"
const shCmd = buildHookCommand(TEST_CONFIG_DIR, 'gsd-validate-commit.sh', HOOK_OPTS);
assert.ok(
typeof shCmd === 'string' && shCmd.includes('bash'),
'buildHookCommand must produce a "bash" command for .sh hooks. ' +
`Got: ${shCmd}`
);
// .js hook must contain "node" (absolute path will include the word "node")
const jsCmd = buildHookCommand(TEST_CONFIG_DIR, 'gsd-something.js', HOOK_OPTS);
assert.ok(
typeof jsCmd === 'string' && jsCmd.includes('node'),
'buildHookCommand must produce a "node" command for .js hooks. ' +
`Got: ${jsCmd}`
);
// Non-vacuousness guard: the two commands must be DIFFERENT so that if
// buildHookCommand stops branching on .sh the test actually fails.
assert.notEqual(
shCmd.split('"')[0], // runner token before the first quoted path
jsCmd.split('"')[0],
'buildHookCommand must use different runners for .sh vs .js hooks'
);
});
});
// ── Tests 2-4: behavioral buildHookCommand checks for each .sh hook ────────
// These replace the former source-grep variable-name scans. We call
// buildHookCommand directly for each .sh hook on both linux and win32 and
// assert three properties that the bugs required:
// (a) the returned command is non-empty
// (b) a bash runner appears in the command (linux path; win32 uses the
// path directly as the invocation, so the check is conditioned on OS)
// (c) the configDir is embedded as an absolute, double-quoted prefix
// Absolute configDir with a space in it exercises the #2045 quoting bug.
const SPACED_CONFIG_DIR = '/home/first last/.claude';
for (const { name } of SH_HOOKS) {
describe(`${name} — buildHookCommand output`, () => {
// ── Test 2: non-empty command on linux ─────────────────────────────────
test(`linux: returns non-empty command (fixes #2046 relative-path crash)`, () => {
const cmd = buildHookCommand(TEST_CONFIG_DIR, name, { platform: 'linux', runtime: 'claude' });
assert.ok(
typeof cmd === 'string' && cmd.length > 0,
`buildHookCommand must return a non-empty string for ${name} on linux. Got: ${String(cmd)}`
);
});
// ── Test 3: bash runner present on linux ───────────────────────────────
test(`linux: command starts with bash runner (fixes #2046 sh dispatch)`, () => {
const cmd = buildHookCommand(TEST_CONFIG_DIR, name, { platform: 'linux', runtime: 'claude' });
// Acceptable forms: "bash <path>", "/usr/bin/bash <path>", etc.
assert.ok(
/\bbash\b/.test(cmd),
`buildHookCommand must include "bash" runner for ${name} on linux. Got: ${cmd}`
);
});
// ── Test 4: absolute, double-quoted configDir on both platforms ─────────
// Uses a configDir containing a space to prove quoting is not incidental.
for (const platform of ['linux', 'win32']) {
test(`${platform}: configDir is absolute and double-quoted (fixes #2045 spaces)`, () => {
const cmd = buildHookCommand(SPACED_CONFIG_DIR, name, { platform, runtime: 'claude' });
// The configDir must appear verbatim inside double quotes in the command.
// e.g. bash "/home/first last/.claude/hooks/gsd-validate-commit.sh"
// or "/home/first last/.claude/hooks/gsd-validate-commit.sh"
assert.ok(
cmd.includes(`"${SPACED_CONFIG_DIR}`),
`buildHookCommand must embed configDir inside double quotes for ${name} on ${platform}. ` +
`Got: ${cmd}`
);
// Confirm the path is absolute (starts with / or drive letter) — not ".claude/..."
const quotedPath = cmd.match(/"([^"]+)"/)?.[1] ?? '';
assert.ok(
path.isAbsolute(quotedPath),
`The quoted path in buildHookCommand output must be absolute for ${name} on ${platform}. ` +
`Got quoted segment: "${quotedPath}" in: ${cmd}`
);
});
}
});
}
// ── Tests 5-7: GLOBAL-install branch (isGlobal=true) for each .sh hook ─────
// The #2045 path-with-spaces bug originally lived in the global-install branch
// of hook registration. These tests call buildHookCommand with isGlobal:true
// for each .sh hook on both linux and win32 and assert:
// (a) the command is non-empty
// (b) bash is used as the runner on linux (not bare concatenation)
// (c) the configDir with spaces is wrapped in double quotes
// (d) the quoted path is absolute (not a relative ".claude/..." fragment)
//
// A regression that reintroduces bare string concatenation on the isGlobal
// branch will produce e.g. `bash /home/first last/.claude/hooks/...` (no
// quotes), which fails assertion (c) and makes these tests go RED.
for (const { name } of SH_HOOKS) {
describe(`${name} — buildHookCommand isGlobal=true`, () => {
// ── Test 5: non-empty command on linux (global) ────────────────────────
test(`linux isGlobal: returns non-empty command`, () => {
const cmd = buildHookCommand(TEST_CONFIG_DIR, name, {
platform: 'linux', runtime: 'claude', isGlobal: true,
});
assert.ok(
typeof cmd === 'string' && cmd.length > 0,
`buildHookCommand(isGlobal=true) must return a non-empty string for ${name} on linux. Got: ${String(cmd)}`
);
});
// ── Test 6: bash runner present on linux (global) ─────────────────────
test(`linux isGlobal: command delegates to bash runner (not bare concatenation)`, () => {
const cmd = buildHookCommand(TEST_CONFIG_DIR, name, {
platform: 'linux', runtime: 'claude', isGlobal: true,
});
// Must contain 'bash' — bare concatenation produces "bash /path with space/..."
// which crashes the shell; the fix puts the path in quotes.
assert.ok(
/\bbash\b/.test(cmd),
`buildHookCommand(isGlobal=true) must include "bash" runner for ${name} on linux. Got: ${cmd}`
);
});
// ── Test 7: absolute, double-quoted configDir on both platforms (global) ─
// Uses SPACED_CONFIG_DIR (contains a space) to ensure the test goes RED
// when bare concatenation is reintroduced: `bash /home/first last/...`
// fails the `cmd.includes('"' + SPACED_CONFIG_DIR)` check.
for (const platform of ['linux', 'win32']) {
test(`${platform} isGlobal: configDir is absolute and double-quoted (guards #2045 global path)`, () => {
const cmd = buildHookCommand(SPACED_CONFIG_DIR, name, {
platform, runtime: 'claude', isGlobal: true,
});
assert.ok(
cmd.includes(`"${SPACED_CONFIG_DIR}`),
`buildHookCommand(isGlobal=true) must embed configDir inside double quotes for ${name} on ${platform}. ` +
`Got: ${cmd}`
);
const quotedPath = cmd.match(/"([^"]+)"/)?.[1] ?? '';
assert.ok(
path.isAbsolute(quotedPath),
`The quoted path in buildHookCommand(isGlobal=true) output must be absolute for ${name} on ${platform}. ` +
`Got quoted segment: "${quotedPath}" in: ${cmd}`
);
});
}
});
}
});