enhance(#3588): add an opt-in commit_docs pre-commit hook (#3609)

* feat(#3588): add an opt-in commit_docs pre-commit hook

Final phase of epic #2292, scope narrowed to opt-in by maintainer decision:
default-on installation and the bin/install.js wiring it would have required
are explicitly out of scope.

Enabling is an explicit verb call. The hook is written to the repo's real hooks
dir resolved via git rev-parse --git-path hooks, so a linked worktree or
submodule whose .git is a FILE works rather than getting a literal .git/hooks
path. It refuses rather than overwrite a foreign pre-commit, refuses to delete
one it did not write, and refuses outright when core.hooksPath is already set --
a written-but-ignored hook is worse than a refusal. Ownership is detected by
marker presence, not byte-equality, so a user who appends a line does not make
it unrecognizable.

Deliberately NOT included: teaching cmdCheckCommit the per-phase commit_docs
tier. #3587 was still unmerged when this landed, and implementing precedence
against helpers that did not yet exist would have meant a second copy of the
resolution chain -- the divergence class this epic has spent three phases
fighting. That follows as its own change now that #3587 is on next.

The ordering constraint is recorded in the design doc: this must not merge
before #3587, or the hook would block a commit cmdCommit itself allows.

* fix(#3588): teach the commit_docs guard the per-phase tier and -z paths

Part 1, deferred until #3587 merged.

cmdCheckCommit read only project-level commit_docs, so once #3587 landed, a
phase with phase_commit_docs true under project false was ALLOWED by
query commit and BLOCKED by this guard -- and the hook shipped in this same
branch shells out to it. It now derives the staged phase via the single-owner
detectPhaseNumberFromFiles and resolves through #3587's own
resolveCommitDocsPolicy rather than a second precedence copy.

Also fixes a proven false negative in the harm direction. git diff --cached
--name-only C-style-quotes any path with non-ASCII or special characters, so a
staged .planning/cafe.md was emitted as a quoted string, failed
startsWith('.planning/'), and slipped past the guard entirely under
commit_docs:false. Reading with -z and splitting on NUL removes the quoting at
the source. The f.startsWith('.planning\\') branch was dead code under that
read -- git emits /-separated paths on every platform -- and is removed rather
than left implying coverage it never provided.

The earlier C7 test pinned the buggy behavior as intended; it now asserts the
file is detected and the commit refused.

Self-caught: the commit-docs-guard verb was wired into the routers by this
branch's earlier pass but missing from the top-level help listing.

* test(#3588): replace try/finally with t.after, add negative-routing cases

Standards review findings.

CONTRIBUTING bans try/finally inside a test body outright -- it masks failures
-- and B8 used one for worktree cleanup. Now t.after(), assertions unchanged.

The new commit-docs-guard command family had zero negative-routing coverage,
which CONTRIBUTING requires for any change to command dispatch. B11-B15 cover
no subcommand, unknown, empty string, whitespace-only and a flag-shaped value,
each asserting non-zero exit, a structured error, no stack trace, and -- the
one that matters for a command that writes into a user's repo -- that NO hook
is written in any of them.

Those tests were verified to fail when routeCommitDocsGuard's else-branch is
neutered, so they exercise the routing guard rather than any convenient error
path.

Also made two error() calls' control flow explicit with a return; they were
safe only because error() is typed never two files away.

* chore(#3588): backfill changeset pr number to 3609

* test(#3588): skip Windows-unrepresentable fixtures on win32

CI's Windows shards caught two of my own tests: fixtures whose filenames
contain a quote and a backslash. Both are illegal on Windows -- backslash is
the path separator, quote is invalid on NTFS -- so fixture creation failed
before any assertion ran.

Test-portability defect, not a production one. Those inputs cannot exist on
that platform, so the guard has nothing to detect there.

Both now check process.platform FIRST, before any fs or git call, and use
t.skip() rather than a bare return -- a bare return registers as a PASS and
would hide the gap it is meant to record. Each carries a comment saying the
input is unrepresentable rather than unverified, so nobody later re-enables it.

No padding added: the cafe.md case already exercises git's C-quoting path on
every platform, since non-ASCII names are legal on NTFS.

This is exactly the coverage the Linux-only remote matrix cannot provide, which
the PR body already stated -- CI's Windows shards are what caught it.

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-18 00:25:32 -04:00
committed by GitHub
parent fba3b9c24f
commit fe64704ace
9 changed files with 864 additions and 19 deletions

View File

@@ -2647,6 +2647,530 @@ describe('check-commit command', () => {
assert.ok(result.error.includes('.planning/'), 'error should mention .planning/ files');
assert.ok(result.error.includes('unstage'), 'error should suggest unstage command');
});
// #3588 F1: cmdCheckCommit must resolve the SAME phase_commit_docs.<phase-id>
// tier `gsd-tools query commit` (cmdCommit) already honors (#3587/#3601).
// Before this fix, cmdCheckCommit read only project-level `commit_docs`, so
// a phase with `phase_commit_docs.<n>: true` under project `commit_docs:
// false` was ALLOWED by `query commit` and BLOCKED by this guard — the
// hook shipped in this same branch shells out to check-commit, so that
// contradiction was live. C4/C5 exercise both directions of the override;
// both fail against the pre-fix tree (project-level-only check).
test('C4 (#3588/#3587): project commit_docs:false + per-phase true ALLOWS the commit', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ commit_docs: false, phase_commit_docs: { '03': true } })
);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-widgets'), { recursive: true });
fs.writeFileSync(path.join(tmpDir, '.planning', 'phases', '03-widgets', 'SUMMARY.md'), '# Three');
gitOrThrow(['add', '.planning/phases/03-widgets/SUMMARY.md'], { cwd: tmpDir });
const result = runGsdTools('check-commit', tmpDir);
assert.ok(
result.success,
`phase_commit_docs.03:true must allow the commit even though project commit_docs is false: ${result.error || ''}`,
);
const output = JSON.parse(result.output);
assert.strictEqual(output.allowed, true);
});
test('C5 (#3588/#3587): project commit_docs:true + per-phase false BLOCKS the commit', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ commit_docs: true, phase_commit_docs: { '03': false } })
);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-widgets'), { recursive: true });
fs.writeFileSync(path.join(tmpDir, '.planning', 'phases', '03-widgets', 'SUMMARY.md'), '# Three');
gitOrThrow(['add', '.planning/phases/03-widgets/SUMMARY.md'], { cwd: tmpDir });
const result = runGsdTools('check-commit', tmpDir);
assert.ok(!result.success, 'phase_commit_docs.03:false must block the commit even though project commit_docs is true');
assert.ok(result.error.includes('03-widgets/SUMMARY.md'), result.error);
});
// #3588 C6: staged paths spanning two phase directories with DIFFERENT
// phase_commit_docs values must resolve against the FIRST phase (in
// detectPhaseNumberFromFiles's staged-path order) — the same first-match
// rule cmdCommit is pinned to (see the folded #3587 `multiPhaseFilesResolves
// AgainstFirstPhase` test above). This replaces the pre-fix baseline test,
// which could only assert the phase-blind "blocks everything" behavior
// because the per-phase tier did not exist here yet.
test('C6 (#3588): staged paths spanning two phase directories resolve against the FIRST phase, matching cmdCommit', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ commit_docs: false, phase_commit_docs: { '01': true, '02': false } })
);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-first'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-second'), { recursive: true });
fs.writeFileSync(path.join(tmpDir, '.planning', 'phases', '01-first', 'SUMMARY.md'), '# One');
fs.writeFileSync(path.join(tmpDir, '.planning', 'phases', '02-second', 'SUMMARY.md'), '# Two');
gitOrThrow(
['add', '.planning/phases/01-first/SUMMARY.md', '.planning/phases/02-second/SUMMARY.md'],
{ cwd: tmpDir }
);
const result = runGsdTools('check-commit', tmpDir);
assert.ok(
result.success,
`phase 01 (first match) resolves phase_commit_docs.01:true, so the commit must be allowed despite phase 02:false: ${result.error || ''}`,
);
});
// #3588 F2: `git diff --cached --name-only` (no `-z`) C-style-quotes any
// path containing a non-ASCII byte or another special character — a staged
// `.planning/café.md` is reported as `".planning/caf\303\251.md"`, which
// does not start with `.planning/`, so the pre-fix guard MISSED it and
// allowed the commit — a false negative in the harm direction this guard
// exists to prevent. These MUST fail against the pre-fix (LF, no `-z`)
// tree and pass once `-z` + NUL-split lands.
test('F2 (#3588): a staged .planning/ file with a non-ASCII name is detected and blocked', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ commit_docs: false })
);
const unicodeName = '.planning/café.md';
fs.writeFileSync(path.join(tmpDir, unicodeName), '# State');
gitOrThrow(['add', unicodeName], { cwd: tmpDir });
const result = runGsdTools('check-commit', tmpDir);
assert.ok(!result.success, 'a staged .planning/café.md must be detected and block the commit');
assert.ok(result.error.includes('café.md'), result.error);
});
test('F2 (#3588): a staged .planning/ file with a space in its name is detected and blocked', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ commit_docs: false })
);
const spacedName = '.planning/with space.md';
fs.writeFileSync(path.join(tmpDir, spacedName), '# State');
gitOrThrow(['add', spacedName], { cwd: tmpDir });
const result = runGsdTools('check-commit', tmpDir);
assert.ok(!result.success, 'a staged .planning/ file with a space in its name must be detected and block the commit');
assert.ok(result.error.includes('with space.md'), result.error);
});
test('F2 (#3588): a staged .planning/ file with a quote character in its name is detected and blocked', (t) => {
// `"` is a reserved NTFS character — a file named `with"quote.md` cannot
// exist on Windows at all, so the fixture itself is unrepresentable
// there. This is not a gap in the guard's Windows behavior; it is an
// input that Windows filesystems reject outright. Do not re-enable this
// on win32 — see #3588.
if (process.platform === 'win32') {
t.skip('a `"` filename is illegal on Windows filesystems (#3588); fixture cannot be created');
return;
}
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ commit_docs: false })
);
const quotedName = '.planning/with"quote.md';
fs.writeFileSync(path.join(tmpDir, quotedName), '# State');
gitOrThrow(['add', quotedName], { cwd: tmpDir });
const result = runGsdTools('check-commit', tmpDir);
assert.ok(!result.success, 'a staged .planning/ file with a quote character in its name must be detected and block the commit');
assert.ok(result.error.includes('quote.md'), result.error);
});
// #3588 C7 (flipped): the earlier pass's C7 test pinned a synthetic
// top-level filename (`.planning\STATE.md`, backslash as a literal
// character in a single path component, not a real nested directory — git
// never uses backslash as a tree separator, on any platform) as evidence
// that `f.startsWith('.planning\\')` was unreachable, and left the assertion
// at "currently allowed" pending a fix. That branch is now removed as dead
// code (git's plumbing output is always `/`-normalized, so a real Windows
// `.planning\<file>` path never reaches this filter as a `.planning\`
// prefix). This replaces it with the REAL analog of the same class of bug:
// a genuine `.planning/` file whose name merely CONTAINS a literal
// backslash character. Without `-z` that name is also C-style-quoted
// (`".planning/back\\slash.md"`) and missed; with `-z` it is read as raw,
// unquoted bytes and correctly detected via the plain `.planning/` prefix
// check alone — no backslash-specific branch needed.
test('C7 (#3588, flipped): a staged .planning/ file whose name contains a backslash character is detected and blocked', (t) => {
// `\` is the Windows path separator, not a legal character inside a
// single filename component — a file literally named `back\slash.md`
// cannot be created on Windows filesystems, so the fixture itself is
// unrepresentable there. This is not a gap in the guard's Windows
// behavior; it is an input Windows rejects outright. Do not re-enable
// this on win32 — see #3588.
if (process.platform === 'win32') {
t.skip('a `\\` filename is illegal on Windows filesystems (#3588); fixture cannot be created');
return;
}
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ commit_docs: false })
);
const backslashInName = '.planning/back\\slash.md';
fs.writeFileSync(path.join(tmpDir, backslashInName), '# State');
gitOrThrow(['add', backslashInName], { cwd: tmpDir });
const result = runGsdTools('check-commit', tmpDir);
assert.ok(
!result.success,
'a staged .planning/ file whose name contains a backslash character must be detected and block the commit',
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// commit-docs-guard: opt-in pre-commit hook (#3588)
// ─────────────────────────────────────────────────────────────────────────────
describe('commit-docs-guard hook script (#3588 A1-A5)', () => {
const { createTempGitProject, TEST_ENV_BASE } = require('./helpers.cjs');
const { runHook } = require('./helpers/process-seam.cjs');
const REPO_ROOT = path.join(__dirname, '..');
const HOOK_MARKER = '# gsd-core:commit-docs-guard';
let tmpDir;
let hookPath;
beforeEach(() => {
tmpDir = createTempGitProject();
const enableResult = runGsdTools('commit-docs-guard enable --raw', tmpDir);
assert.ok(enableResult.success, `enable failed: ${enableResult.error}`);
hookPath = path.join(tmpDir, '.git', 'hooks', 'pre-commit');
});
afterEach(() => {
cleanup(tmpDir);
});
test('A1: hook script carries the gsd-core:commit-docs-guard marker', () => {
const content = fs.readFileSync(hookPath, 'utf8');
assert.ok(content.includes(HOOK_MARKER), 'written hook must carry the marker line');
});
test('A2: hook script uses LF-only line endings (boundary — Windows)', () => {
const content = fs.readFileSync(hookPath, 'utf8');
assert.ok(!content.includes('\r'), 'a CRLF shebang is not executable under Git Bash');
});
test('A3: hook file is executable after enable', () => {
if (process.platform === 'win32') return; // exec bit is not the Windows-relevant assertion
const mode = fs.statSync(hookPath).mode;
assert.ok((mode & 0o111) !== 0, 'pre-commit hook must carry the executable bit');
});
test('A4: hook exits zero when the guard allows', () => {
fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ commit_docs: true }));
const result = runHook(hookPath, [], {
interpreter: 'bash',
cwd: tmpDir,
env: { ...process.env, ...TEST_ENV_BASE, RUNTIME_DIR: REPO_ROOT },
});
assert.strictEqual(result.exitCode, 0, `stdout=${result.stdout} stderr=${result.stderr}`);
});
test('A5: hook exits non-zero and names the staged files when the guard blocks', () => {
fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ commit_docs: false }));
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '# State');
gitOrThrow(['add', '.planning/STATE.md'], { cwd: tmpDir });
const result = runHook(hookPath, [], {
interpreter: 'bash',
cwd: tmpDir,
env: { ...process.env, ...TEST_ENV_BASE, RUNTIME_DIR: REPO_ROOT },
});
assert.notStrictEqual(result.exitCode, 0, 'hook must exit non-zero when the guard blocks');
assert.ok(result.stderr.includes('.planning/STATE.md'), result.stderr);
});
});
describe('commit-docs-guard enable/disable (#3588 B1-B15)', () => {
const { createTempGitProject } = require('./helpers.cjs');
let tmpDir;
afterEach(() => {
if (tmpDir) cleanup(tmpDir);
tmpDir = undefined;
});
test('B1: enable writes an executable hook and reports success', () => {
tmpDir = createTempGitProject();
const result = runGsdTools('commit-docs-guard enable --raw', tmpDir);
assert.ok(result.success, result.error);
const hookPath = path.join(tmpDir, '.git', 'hooks', 'pre-commit');
assert.ok(fs.existsSync(hookPath));
if (process.platform !== 'win32') {
assert.ok((fs.statSync(hookPath).mode & 0o111) !== 0);
}
});
test('B2: enable refuses to clobber an existing foreign pre-commit hook', () => {
tmpDir = createTempGitProject();
const hookPath = path.join(tmpDir, '.git', 'hooks', 'pre-commit');
const foreignContent = '#!/bin/sh\necho foreign\n';
fs.writeFileSync(hookPath, foreignContent);
fs.chmodSync(hookPath, 0o755);
const result = runGsdTools('commit-docs-guard enable --raw', tmpDir);
assert.ok(!result.success, 'enable must refuse to overwrite a foreign hook');
assert.ok(result.error.includes(hookPath), result.error);
assert.strictEqual(fs.readFileSync(hookPath, 'utf8'), foreignContent, 'foreign hook must be byte-unchanged');
});
test('B3: enable twice is idempotent — no duplicated content', () => {
tmpDir = createTempGitProject();
const r1 = runGsdTools('commit-docs-guard enable --raw', tmpDir);
assert.ok(r1.success, r1.error);
const hookPath = path.join(tmpDir, '.git', 'hooks', 'pre-commit');
const first = fs.readFileSync(hookPath, 'utf8');
const r2 = runGsdTools('commit-docs-guard enable --raw', tmpDir);
assert.ok(r2.success, r2.error);
const second = fs.readFileSync(hookPath, 'utf8');
assert.strictEqual(second, first, 'a second enable must not change or duplicate content');
const markerCount = (second.match(/# gsd-core:commit-docs-guard/g) || []).length;
assert.strictEqual(markerCount, 1, 'exactly one marker line, never duplicated');
});
test('B4: disable removes our hook', () => {
tmpDir = createTempGitProject();
const enableResult = runGsdTools('commit-docs-guard enable --raw', tmpDir);
assert.ok(enableResult.success, enableResult.error);
const hookPath = path.join(tmpDir, '.git', 'hooks', 'pre-commit');
assert.ok(fs.existsSync(hookPath));
const result = runGsdTools('commit-docs-guard disable --raw', tmpDir);
assert.ok(result.success, result.error);
assert.ok(!fs.existsSync(hookPath));
});
test('B5: disable refuses to remove a foreign hook', () => {
tmpDir = createTempGitProject();
const hookPath = path.join(tmpDir, '.git', 'hooks', 'pre-commit');
const foreignContent = '#!/bin/sh\necho foreign\n';
fs.writeFileSync(hookPath, foreignContent);
fs.chmodSync(hookPath, 0o755);
const result = runGsdTools('commit-docs-guard disable --raw', tmpDir);
assert.ok(!result.success, 'disable must refuse to remove a foreign hook');
assert.strictEqual(fs.readFileSync(hookPath, 'utf8'), foreignContent, 'foreign hook must be byte-unchanged');
});
test('B6: disable with no hook present is a success no-op, not an error', () => {
tmpDir = createTempGitProject();
const result = runGsdTools('commit-docs-guard disable --raw', tmpDir);
assert.ok(result.success, result.error);
});
test('B7: enable outside a git repository fails cleanly, nothing written', () => {
tmpDir = createTempDir();
const before = fs.readdirSync(tmpDir);
const result = runGsdTools('commit-docs-guard enable --raw', tmpDir);
assert.ok(!result.success, 'enable must fail outside a git repository');
assert.deepStrictEqual(fs.readdirSync(tmpDir), before, 'nothing may be written');
});
test('B8: enable resolves the real hooks dir when .git is a worktree file', (t) => {
tmpDir = createTempGitProject();
const worktreeParent = createTempDir();
t.after(() => cleanup(worktreeParent));
const wtDir = path.join(worktreeParent, 'wt');
gitOrThrow(['worktree', 'add', wtDir, '-b', 'gsd-test-commit-docs-guard-wt'], { cwd: tmpDir });
assert.ok(fs.statSync(path.join(wtDir, '.git')).isFile(), 'precondition: .git must be a file in a linked worktree');
const result = runGsdTools('commit-docs-guard enable --raw', wtDir);
assert.ok(result.success, result.error);
// Hooks are shared across worktrees in the COMMON git dir — never a
// literal `<worktree>/.git/hooks`.
assert.ok(!fs.existsSync(path.join(wtDir, '.git', 'hooks')), 'must never write a literal <worktree>/.git/hooks path');
const commonHookPath = path.join(tmpDir, '.git', 'hooks', 'pre-commit');
assert.ok(fs.existsSync(commonHookPath), 'hook must land in the real (common) hooks directory');
});
test('B9: enable refuses when core.hooksPath is already set', () => {
tmpDir = createTempGitProject();
gitOrThrow(['config', 'core.hooksPath', 'custom-hooks'], { cwd: tmpDir });
const result = runGsdTools('commit-docs-guard enable --raw', tmpDir);
assert.ok(!result.success, 'enable must refuse when core.hooksPath is set');
assert.ok(result.error.includes('core.hooksPath'), result.error);
assert.ok(!fs.existsSync(path.join(tmpDir, 'custom-hooks', 'pre-commit')), 'must not write into the hooksPath-configured dir either');
assert.ok(!fs.existsSync(path.join(tmpDir, '.git', 'hooks', 'pre-commit')), 'must not write the ordinary hooks dir either');
});
test('B10: marker detection tolerates a user-appended line', () => {
tmpDir = createTempGitProject();
const r1 = runGsdTools('commit-docs-guard enable --raw', tmpDir);
assert.ok(r1.success, r1.error);
const hookPath = path.join(tmpDir, '.git', 'hooks', 'pre-commit');
fs.appendFileSync(hookPath, '\n# a user comment appended after install\n');
const r2 = runGsdTools('commit-docs-guard enable --raw', tmpDir);
assert.ok(r2.success, `enable must still recognize the edited hook as ours: ${r2.error}`);
assert.ok(
fs.readFileSync(hookPath, 'utf8').includes('a user comment appended after install'),
'enable must not silently discard the user edit on a recognized hook'
);
const r3 = runGsdTools('commit-docs-guard disable --raw', tmpDir);
assert.ok(r3.success, `disable must still recognize the edited hook as ours: ${r3.error}`);
assert.ok(!fs.existsSync(hookPath));
});
test('B11: no subcommand at all hits the unknown-subcommand routing guard', () => {
tmpDir = createTempGitProject();
const hookPath = path.join(tmpDir, '.git', 'hooks', 'pre-commit');
const result = runGsdTools(['--json-errors', 'commit-docs-guard'], tmpDir);
assert.ok(!result.success, 'missing subcommand must not succeed');
assert.notStrictEqual(result.exitCode, 0, 'missing subcommand must exit non-zero');
const parsed = JSON.parse(result.error);
assert.deepStrictEqual(Object.keys(parsed).sort(), ['message', 'ok', 'reason']);
assert.strictEqual(parsed.ok, false);
assert.strictEqual(parsed.reason, 'sdk_unknown_command');
assert.strictEqual(/\n\s*at\s/.test(result.error), false, 'non-debug failure must not print a stack trace');
assert.ok(!fs.existsSync(hookPath), 'no hook may be written for a missing subcommand');
});
test('B12: an unknown subcommand hits the unknown-subcommand routing guard', () => {
tmpDir = createTempGitProject();
const hookPath = path.join(tmpDir, '.git', 'hooks', 'pre-commit');
const result = runGsdTools(['--json-errors', 'commit-docs-guard', 'bogus'], tmpDir);
assert.ok(!result.success, 'an unrecognized subcommand must not succeed');
assert.notStrictEqual(result.exitCode, 0, 'an unrecognized subcommand must exit non-zero');
const parsed = JSON.parse(result.error);
assert.deepStrictEqual(Object.keys(parsed).sort(), ['message', 'ok', 'reason']);
assert.strictEqual(parsed.ok, false);
assert.strictEqual(parsed.reason, 'sdk_unknown_command');
assert.strictEqual(/\n\s*at\s/.test(result.error), false, 'non-debug failure must not print a stack trace');
assert.ok(!fs.existsSync(hookPath), 'no hook may be written for an unrecognized subcommand');
});
test('B13: an empty-string subcommand hits the unknown-subcommand routing guard', () => {
tmpDir = createTempGitProject();
const hookPath = path.join(tmpDir, '.git', 'hooks', 'pre-commit');
const result = runGsdTools(['--json-errors', 'commit-docs-guard', ''], tmpDir);
assert.ok(!result.success, 'an empty-string subcommand must not succeed');
assert.notStrictEqual(result.exitCode, 0, 'an empty-string subcommand must exit non-zero');
const parsed = JSON.parse(result.error);
assert.deepStrictEqual(Object.keys(parsed).sort(), ['message', 'ok', 'reason']);
assert.strictEqual(parsed.ok, false);
assert.strictEqual(parsed.reason, 'sdk_unknown_command');
assert.strictEqual(/\n\s*at\s/.test(result.error), false, 'non-debug failure must not print a stack trace');
assert.ok(!fs.existsSync(hookPath), 'no hook may be written for an empty-string subcommand');
});
test('B14: a whitespace-only subcommand hits the unknown-subcommand routing guard', () => {
tmpDir = createTempGitProject();
const hookPath = path.join(tmpDir, '.git', 'hooks', 'pre-commit');
const result = runGsdTools(['--json-errors', 'commit-docs-guard', ' '], tmpDir);
assert.ok(!result.success, 'a whitespace-only subcommand must not succeed');
assert.notStrictEqual(result.exitCode, 0, 'a whitespace-only subcommand must exit non-zero');
const parsed = JSON.parse(result.error);
assert.deepStrictEqual(Object.keys(parsed).sort(), ['message', 'ok', 'reason']);
assert.strictEqual(parsed.ok, false);
assert.strictEqual(parsed.reason, 'sdk_unknown_command');
assert.strictEqual(/\n\s*at\s/.test(result.error), false, 'non-debug failure must not print a stack trace');
assert.ok(!fs.existsSync(hookPath), 'no hook may be written for a whitespace-only subcommand');
});
test('B15: a flag-shaped value in the subcommand position hits the unknown-subcommand routing guard', () => {
tmpDir = createTempGitProject();
const hookPath = path.join(tmpDir, '.git', 'hooks', 'pre-commit');
const result = runGsdTools(['--json-errors', 'commit-docs-guard', '--enable'], tmpDir);
assert.ok(!result.success, 'a flag-shaped subcommand must not succeed');
assert.notStrictEqual(result.exitCode, 0, 'a flag-shaped subcommand must exit non-zero');
const parsed = JSON.parse(result.error);
assert.deepStrictEqual(Object.keys(parsed).sort(), ['message', 'ok', 'reason']);
assert.strictEqual(parsed.ok, false);
assert.strictEqual(parsed.reason, 'sdk_unknown_command');
assert.strictEqual(/\n\s*at\s/.test(result.error), false, 'non-debug failure must not print a stack trace');
assert.ok(!fs.existsSync(hookPath), 'no hook may be written for a flag-shaped subcommand');
});
});
describe('commit-docs-guard real git commit wiring (#3588 D1-D3)', () => {
const { createTempGitProject, TEST_ENV_BASE } = require('./helpers.cjs');
const { runGit } = require('./helpers/process-seam.cjs');
const REPO_ROOT = path.join(__dirname, '..');
let tmpDir;
beforeEach(() => {
tmpDir = createTempGitProject();
const enableResult = runGsdTools('commit-docs-guard enable --raw', tmpDir);
assert.ok(enableResult.success, `enable failed: ${enableResult.error}`);
});
afterEach(() => {
cleanup(tmpDir);
});
function stagePlanningFile(name) {
fs.writeFileSync(path.join(tmpDir, '.planning', name), '# State\n');
gitOrThrow(['add', `.planning/${name}`], { cwd: tmpDir });
}
function commitEnv() {
// RUNTIME_DIR pins the hook's gsd_run resolution (the
// _runtime-launcher.snippet.sh preamble) to THIS checkout's own
// gsd-core/bin/gsd-tools.cjs rather than relying on an ambient PATH
// install or a config-dir fallback that would not exist in CI.
return { ...process.env, ...TEST_ENV_BASE, RUNTIME_DIR: REPO_ROOT };
}
function commitCount() {
return Number(gitOrThrow(['rev-list', '--count', 'HEAD'], { cwd: tmpDir }).trim());
}
test('D1: a real `git commit` is refused when commit_docs is false and .planning/ is staged', () => {
fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ commit_docs: false }));
stagePlanningFile('STATE.md');
const before = commitCount();
const result = runGit(['commit', '-m', 'chore: should be refused'], { cwd: tmpDir, env: commitEnv() });
assert.notStrictEqual(result.exitCode, 0, `commit should have been refused; stdout=${result.stdout} stderr=${result.stderr}`);
assert.strictEqual(commitCount(), before, 'nothing should have been committed');
});
test('D2: a real `git commit` succeeds when commit_docs is true', () => {
fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ commit_docs: true }));
stagePlanningFile('STATE.md');
const before = commitCount();
const result = runGit(['commit', '-m', 'chore: should succeed'], { cwd: tmpDir, env: commitEnv() });
assert.strictEqual(result.exitCode, 0, `commit should have succeeded; stdout=${result.stdout} stderr=${result.stderr}`);
assert.strictEqual(commitCount(), before + 1);
});
test('D3: disable actually unwires the hook — the same D1 scenario now succeeds', () => {
const disableResult = runGsdTools('commit-docs-guard disable --raw', tmpDir);
assert.ok(disableResult.success, disableResult.error);
fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ commit_docs: false }));
stagePlanningFile('STATE.md');
const before = commitCount();
const result = runGit(['commit', '-m', 'chore: allowed after disable'], { cwd: tmpDir, env: commitEnv() });
assert.strictEqual(result.exitCode, 0, `commit should have succeeded after disable; stdout=${result.stdout} stderr=${result.stderr}`);
assert.strictEqual(commitCount(), before + 1);
});
});
describe('commit-docs-guard default install scope guarantee (#3588 E2)', () => {
test('E2: the default install path wires nothing new — commit-docs-guard is opt-in only', () => {
// #3588 scope guarantee (40-design.md): bin/install.js wiring is
// explicitly OUT of scope. This is the regression lock for that
// narrowing — checked structurally (require()'d typed exports, never
// source-text grep) against the THREE surfaces that would make the hook
// install by default.
const { MANAGED_HOOKS } = require('../hooks/managed-hooks-registry.cjs');
assert.ok(
!MANAGED_HOOKS.some((h) => h.includes('commit-docs-guard')),
'commit-docs-guard must not be a MANAGED_HOOKS install-time hook'
);
// scripts/build-hooks.js HOOKS_TO_COPY is the single source of truth for
// both the shared hooks/dist/ bundle AND bin/install.js's
// INSTALLED_HOOK_FILES/GSD_UNINSTALL_HOOKS (see the "new hook-script
// registration invariants" ripple) — asserting against the exported
// array itself, not grepping either file's source text.
const { HOOKS_TO_COPY } = require('../scripts/build-hooks.js');
assert.ok(
!HOOKS_TO_COPY.includes('commit-docs-guard'),
'commit-docs-guard must not be copied into the shared hooks bundle'
);
const { GSD_UNINSTALL_HOOKS } = require('../bin/install.js');
assert.ok(
!GSD_UNINSTALL_HOOKS.includes('commit-docs-guard'),
'bin/install.js must not list commit-docs-guard among installed/uninstalled hook files'
);
});
});
describe('_wsParseRetryAfter (#308)', () => {