Batch 3 of the ad hoc timeout literal migration (epic #4445). Replaces every bare numeric timeout/timeoutMs object-literal property in tests/ci-rebase-check.test.cjs, tests/gsd-validate-commit-crash-policy.test.cjs, tests/pr-branch-planning-filter.test.cjs, tests/reapply-verify-hunks.test.cjs, tests/ship-notes-wedged-pr.test.cjs, tests/slug-derivation-drift-guard.test.cjs, and tests/worktree-safety.test.cjs with a named constant, per eslint-rules/no-adhoc-timeout-literal.cjs. Removes the 7 files from the rule's allowlist. Mints a new shared class norm, QUICK_SPAWN_TIMEOUT_MS (10000ms), in tests/helpers/timeouts.cjs: 5 sites across 4 of this batch's files had independently arrived at the same value for the same shape (a cheap, trivial subprocess/hook invocation with no real git/network/fan-out work). No src/bin file touched, no numeric value changed anywhere. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
183 lines
8.5 KiB
JavaScript
183 lines
8.5 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* gsd-validate-commit-crash-policy.test.cjs — regression coverage for #3838
|
|
* (subsumed-but-still-present under #3911): hooks/gsd-validate-commit.sh has
|
|
* three "swallow-and-pass" sites — the opt-in config read, the JSON command
|
|
* extraction, and the isGitSubcommand classifier — each of which used a
|
|
* failing subprocess call directly as an `if`/`$(...)` condition. `set -e`
|
|
* never fires on a command used as an `if` condition, so a failure there was
|
|
* indistinguishable from "genuinely not applicable" and silently disabled
|
|
* the whole validator: a non-conforming commit exited 0 with no output,
|
|
* identical to "your commit is fine".
|
|
*
|
|
* This is a sibling file to tests/hooks-crash-policy.test.cjs rather than an
|
|
* addition to its table: that file's TABLE and drift guard are scoped
|
|
* exclusively to hooks/*.js (the hook-exit.js allow/deny/crash migration,
|
|
* #3911 phase 7); gsd-validate-commit.sh is a bash script with its own
|
|
* ad hoc exit-code contract that predates and is orthogonal to that
|
|
* migration, so it does not belong in that table or its drift guard.
|
|
*
|
|
* Every case here spawns the real hook via bash (tests/helpers/process-seam.cjs
|
|
* `runHook` with `interpreter: 'bash'`) against a real fixture project —
|
|
* no source-file grep, no mocked node internals.
|
|
*/
|
|
|
|
const { describe, test, before, after } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const { createTempDir, cleanup, TEST_ENV_BASE } = require('./helpers.cjs');
|
|
const { runHook, OUTCOME } = require('./helpers/process-seam.cjs');
|
|
|
|
/**
|
|
* gsd-validate-commit.sh is invoked via bash and internally fans out to
|
|
* nested `node -e` calls (the classifier/config-read/JSON-extraction
|
|
* sites this file's own header docblock describes) -- conceptually the
|
|
* same shape `HOOK_FANOUT_TIMEOUT_MS` (60000ms) describes, but this hook
|
|
* sits at a much lighter pre-existing value. Not reclassified/raised: no
|
|
* bench citation exists for whether this specific hook needs more.
|
|
* Pre-existing value, unchanged by this migration (#4514).
|
|
*/
|
|
const VALIDATE_COMMIT_HOOK_TIMEOUT_MS = 15000;
|
|
|
|
const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-validate-commit.sh');
|
|
|
|
const CONFORMING_COMMIT_PAYLOAD = JSON.stringify({
|
|
tool_input: { command: 'git commit -m "feat: add thing"' },
|
|
});
|
|
const NONCONFORMING_COMMIT_PAYLOAD = JSON.stringify({
|
|
tool_input: { command: 'git commit -m "wibble wobble"' },
|
|
});
|
|
|
|
const cleanupPaths = [];
|
|
function tempDir(prefix) {
|
|
const dir = createTempDir(prefix);
|
|
cleanupPaths.push(dir);
|
|
return dir;
|
|
}
|
|
|
|
let enabledProject;
|
|
// A PATH directory whose `node` shim fails ONLY the classifier's `node -e`
|
|
// invocation (detected by the presence of `isGitSubcommand` in argv, which
|
|
// only that one of the hook's three node calls ever passes), and otherwise
|
|
// execs the real node binary the test runner itself is running under. This
|
|
// reproduces the exact defect-triggering shape from #3838's repro ("node
|
|
// cannot run [for this call]") without touching the other two call sites.
|
|
let classifierBrokenPathDir;
|
|
|
|
before(() => {
|
|
enabledProject = tempDir('gsd-validate-commit-ok-');
|
|
fs.mkdirSync(path.join(enabledProject, '.planning'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(enabledProject, '.planning', 'config.json'),
|
|
JSON.stringify({ hooks: { community: true } }),
|
|
);
|
|
|
|
classifierBrokenPathDir = tempDir('gsd-validate-commit-node-shim-');
|
|
const shimPath = path.join(classifierBrokenPathDir, 'node');
|
|
fs.writeFileSync(
|
|
shimPath,
|
|
[
|
|
'#!/usr/bin/env bash',
|
|
`REAL_NODE=${JSON.stringify(process.execPath)}`,
|
|
'for a in "$@"; do',
|
|
' if [[ "$a" == *isGitSubcommand* ]]; then',
|
|
' echo "test-shim: classifier node call intentionally broken (simulated node crash)" >&2',
|
|
' exit 127',
|
|
' fi',
|
|
'done',
|
|
'exec "$REAL_NODE" "$@"',
|
|
'',
|
|
].join('\n'),
|
|
);
|
|
fs.chmodSync(shimPath, 0o755);
|
|
});
|
|
|
|
after(() => {
|
|
for (const p of cleanupPaths) cleanup(p);
|
|
});
|
|
|
|
function runValidateCommit({ payload, cwd, env } = {}) {
|
|
return runHook(HOOK_PATH, [], {
|
|
interpreter: 'bash',
|
|
cwd,
|
|
env: { ...process.env, ...TEST_ENV_BASE, ...env },
|
|
input: payload,
|
|
timeoutMs: VALIDATE_COMMIT_HOOK_TIMEOUT_MS,
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Controls — pin that the fix does not weaken or break the working paths.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('gsd-validate-commit.sh: controls (unchanged behavior)', () => {
|
|
test('CONTROL A: node available, conforming commit -> exit 0, no block payload', () => {
|
|
const r = runValidateCommit({ payload: CONFORMING_COMMIT_PAYLOAD, cwd: enabledProject });
|
|
assert.equal(r.outcome, OUTCOME.EXITED, `stderr=${r.stderr}`);
|
|
assert.equal(r.exitCode, 0, `stdout=${r.stdout} stderr=${r.stderr}`);
|
|
assert.equal(r.stdout.trim(), '');
|
|
});
|
|
|
|
test('CONTROL B: node available, non-conforming commit -> exit 2 with block payload', () => {
|
|
const r = runValidateCommit({ payload: NONCONFORMING_COMMIT_PAYLOAD, cwd: enabledProject });
|
|
assert.equal(r.outcome, OUTCOME.EXITED, `stderr=${r.stderr}`);
|
|
assert.equal(r.exitCode, 2, `stdout=${r.stdout} stderr=${r.stderr}`);
|
|
const out = JSON.parse(r.stdout);
|
|
assert.equal(out.decision, 'block');
|
|
assert.equal(out.code, 'CONVENTIONAL_COMMITS_VIOLATION');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Defect arms — each of the three swallow-and-pass sites, exercised with a
|
|
// non-conforming commit so a silent pass is unambiguous: the validator MUST
|
|
// NOT report success by omission when it could not actually run.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('gsd-validate-commit.sh: #3838 could-not-run sites are surfaced, not swallowed', () => {
|
|
test('DEFECT (classifier): node cannot run isGitSubcommand -> not a silent pass', () => {
|
|
const r = runValidateCommit({
|
|
payload: NONCONFORMING_COMMIT_PAYLOAD,
|
|
cwd: enabledProject,
|
|
env: { PATH: `${classifierBrokenPathDir}:${process.env.PATH}` },
|
|
});
|
|
assert.equal(r.outcome, OUTCOME.EXITED, `stderr=${r.stderr}`);
|
|
// The hook must still exit 0 (PreToolUse "fail open"), but it must not
|
|
// be the pre-#3838-fix silent exit 0 with empty stdout AND empty stderr
|
|
// — that shape is indistinguishable from "your commit is fine".
|
|
assert.equal(r.exitCode, 0, `stdout=${r.stdout} stderr=${r.stderr}`);
|
|
assert.notEqual(r.stderr.trim(), '', 'expected a non-empty stderr diagnostic; got silence (the #3838 defect shape)');
|
|
assert.match(r.stderr, /classif/i, 'diagnostic should name the classifier check');
|
|
assert.equal(r.stdout.trim(), '', 'no block payload is expected when validation could not run');
|
|
});
|
|
|
|
test('DEFECT (config read): malformed .planning/config.json -> not a silent pass', () => {
|
|
const brokenConfigProject = tempDir('gsd-validate-commit-badconfig-');
|
|
fs.mkdirSync(path.join(brokenConfigProject, '.planning'), { recursive: true });
|
|
// Syntactically invalid JSON -> require() throws a SyntaxError distinct
|
|
// from "file legitimately says community:false/absent".
|
|
fs.writeFileSync(path.join(brokenConfigProject, '.planning', 'config.json'), '{ this is not json');
|
|
|
|
const r = runValidateCommit({ payload: NONCONFORMING_COMMIT_PAYLOAD, cwd: brokenConfigProject });
|
|
assert.equal(r.outcome, OUTCOME.EXITED, `stderr=${r.stderr}`);
|
|
assert.equal(r.exitCode, 0, `stdout=${r.stdout} stderr=${r.stderr}`);
|
|
assert.notEqual(r.stderr.trim(), '', 'expected a non-empty stderr diagnostic; got silence');
|
|
assert.match(r.stderr, /config/i, 'diagnostic should name the config-read check');
|
|
});
|
|
|
|
test('DEFECT (command extraction): malformed JSON on stdin -> not a silent pass', () => {
|
|
// Malformed top-level JSON on stdin makes JSON.parse(d) throw inside the
|
|
// command-extraction node call — distinct from "tool_input.command is
|
|
// genuinely absent from a well-formed payload" (which legitimately
|
|
// yields CMD='' and is not a git commit).
|
|
const r = runValidateCommit({ payload: '{not valid json at all', cwd: enabledProject });
|
|
assert.equal(r.outcome, OUTCOME.EXITED, `stderr=${r.stderr}`);
|
|
assert.equal(r.exitCode, 0, `stdout=${r.stdout} stderr=${r.stderr}`);
|
|
assert.notEqual(r.stderr.trim(), '', 'expected a non-empty stderr diagnostic; got silence');
|
|
assert.match(r.stderr, /command/i, 'diagnostic should name the command-extraction check');
|
|
});
|
|
});
|