test(#2974): migrate 8 test files to typed-IR assertions (#3016)

* test(#2974): migrate 8 test files to typed-IR assertions

Replaces raw stdout/stderr substring matching with structured-field
assertions per CONTRIBUTING.md "Prohibited: Raw Text Matching on Test
Outputs". Adds shared infrastructure for typed error emission so this
pattern is the easy path going forward.

Shared infrastructure:
- core.cjs: ERROR_REASON frozen enum + setJsonErrorMode/getJsonErrorMode
- gsd-tools.cjs: --json-errors CLI flag, parsed before subcommand dispatch
- config.cjs: typed reasons at all 7 error sites
- graphify.cjs: GRAPHIFY_REASON enum + reason/timeout_ms in execGraphify result
- bin/install.js: pure buildSdkFailFastReport() IR builder + renderer
- hooks/gsd-session-state.sh, gsd-phase-boundary.sh: emit Claude Code
  hookSpecificOutput JSON envelope with typed state_present/config_mode/
  planning_modified/file_path fields (no-op when hooks.community is off)

Test migrations (all pass, 171 tests across the 8 files):
- bug-2649-sdk-fail-fast: assert on ir.reason / ir.context / ir.fix_command
- bug-2687-config-read-warning-parity: assert.equal stderr === ''
- bug-2796-arg-parsing-regression: assert on result.json.updated/.phase
- bug-2838-summary-rescue: parse rescue footer, assert mtime invariant
- bug-2943-config-get-context-window: parse JSON, assert ERROR_REASON.CONFIG_KEY_NOT_FOUND
- graphify: assert reason === GRAPHIFY_REASON.ENOENT/TIMEOUT
- hooks-opt-in: parse hookSpecificOutput, assert typed fields
- security-scan: reclassified as source-text-is-the-product (scan label
  output and CI workflow YAML ARE the deployed contract)

Verification: lint-no-source-grep clean (0 violations), full suite
6741/6741 pass.

Closes #2974

* test(#2974): address CR feedback — typed code field, robust idempotency

Two CodeRabbit findings on #3016 addressed:

1. tests/hooks-opt-in.test.cjs:355 (Minor, inline) —
   parsed.reason.includes('Conventional Commits') was still substring
   matching after the typed-IR migration. Fixed at the source: the
   gsd-validate-commit hook now emits a typed `code` field
   ('CONVENTIONAL_COMMITS_VIOLATION', 'COMMIT_SUBJECT_TOO_LONG')
   alongside the human-readable `reason`. Test asserts strictEqual
   on the code; the prose copy is no longer part of the test contract.

2. tests/bug-2838-summary-rescue-gitignored-planning.test.cjs:224-250
   (Outside-diff) — mtimeMs alone can stay unchanged on coarse-grained
   filesystems (HFS+, FAT) when two rewrites land within the same
   timestamp tick, falsely passing the idempotency assertion.
   Replaced with a full snapshot (mtimeMs, ctimeMs, size, ino, sha256
   of contents) compared via assert.deepStrictEqual — the hash
   catches any rewrite the timestamp would miss.

Verification: 30/30 pass on the two affected files; lint-no-source-grep
clean (0 violations across 368 test files).
This commit is contained in:
Tom Boucher
2026-05-02 09:27:23 -04:00
committed by GitHub
parent de25400b70
commit f55069ecbf
17 changed files with 470 additions and 178 deletions

View File

@@ -11,9 +11,12 @@
'use strict';
// allow-test-rule: pending-migration-to-typed-ir [#2974]
// Tracked in #2974 for migration to typed-IR assertions per CONTRIBUTING.md
// "Prohibited: Raw Text Matching on Test Outputs". Do not copy this pattern.
// Migrated to typed-IR (#2974): the previous shape used regex assertions
// against stderr to verify fix-command and missing-artifact phrases. Now
// the test calls buildSdkFailFastReport(sdkDir) directly and asserts on
// the structured IR fields (reason, context, fix_command, missing_artifact,
// attempted_nested_install). The renderer's text shape is now an
// implementation detail.
const { test, describe, before } = require('node:test');
const assert = require('node:assert/strict');
@@ -128,19 +131,11 @@ describe('installer SDK dist-missing fail-fast (#2649)', () => {
test('missing dist in npx cache: fail fast, no nested npm install', () => {
const { root, sdkDir } = makeTempSdk({ npxCache: true });
try {
// Behavioral check 1: installSdkIfNeeded exits 1 and never spawns nested npm.
const result = runWithIntercepts(() => {
installer.installSdkIfNeeded({ sdkDir });
});
assert.strictEqual(result.exitCode, 1, 'must exit non-zero');
// (a) actionable upgrade path in error output
assert.match(result.stderr, /npm i(nstall)? -g get-shit-done-cc@latest/,
'error must mention the global-install upgrade path');
assert.match(result.stderr, /sdk\/dist/,
'error must name the missing artifact');
// (b) no nested `npm install` / `npm.cmd install` inside sdkDir
const nestedInstall = result.spawnCalls.find((c) => {
const argv = Array.isArray(c.argv) ? c.argv : [];
const cwd = c.opts && c.opts.cwd;
@@ -151,6 +146,18 @@ describe('installer SDK dist-missing fail-fast (#2649)', () => {
});
assert.strictEqual(nestedInstall, undefined,
'must NOT spawn `npm install` inside the npx-cache sdk dir');
// Behavioral check 2: the structured fail-fast IR identifies the npx-cache
// context, points at the right fix command, and asserts the no-nested-install
// contract via a typed boolean (no stderr grepping).
const ir = installer.buildSdkFailFastReport(sdkDir, path.join(sdkDir, 'dist', 'cli.js'));
assert.strictEqual(ir.ok, false);
assert.strictEqual(ir.reason, 'sdk_fail_fast');
assert.strictEqual(ir.context, 'npx-cache');
assert.strictEqual(ir.missing_artifact, 'sdk/dist');
assert.strictEqual(ir.fix_command, 'npm install -g get-shit-done-cc@latest');
assert.strictEqual(ir.attempted_nested_install, false,
'IR contract: nested-install must always be false (this is a hard invariant)');
} finally {
cleanup(root);
}
@@ -164,9 +171,13 @@ describe('installer SDK dist-missing fail-fast (#2649)', () => {
installer.installSdkIfNeeded({ sdkDir });
});
assert.strictEqual(result.exitCode, 1);
// Dev clone path: suggest the local build, not the global upgrade.
assert.match(result.stderr, /cd sdk && npm install && npm run build/,
'dev-clone error must keep the build-from-clone instructions');
// Typed-IR migration #2974: dev-clone context surfaces the local-build
// fix command (not the global-install one).
const ir = installer.buildSdkFailFastReport(sdkDir, path.join(sdkDir, 'dist', 'cli.js'));
assert.strictEqual(ir.context, 'dev-clone');
assert.strictEqual(ir.fix_command, 'cd sdk && npm install && npm run build');
assert.strictEqual(ir.attempted_nested_install, false);
const nestedInstall = result.spawnCalls.find((c) => {
const argv = Array.isArray(c.argv) ? c.argv : [];

View File

@@ -1,9 +1,5 @@
'use strict';
// allow-test-rule: pending-migration-to-typed-ir [#2974]
// Tracked in #2974 for migration to typed-IR assertions per CONTRIBUTING.md
// "Prohibited: Raw Text Matching on Test Outputs". Do not copy this pattern.
/**
* Regression test for #2687 — loadConfig must not emit "unknown config key"
* warnings for keys that are registered in DYNAMIC_KEY_PATTERNS (e.g. review,
@@ -11,10 +7,14 @@
* the hand-maintained KNOWN_TOP_LEVEL set in core.cjs, causing false-positive
* warnings on every read.
*
* We trigger loadConfig via `resolve-model` (which calls loadConfig internally).
* We use spawnSync to capture stderr from a process that exits 0 (warnings are
* written to stderr but don't cause a non-zero exit, so runGsdTools' error field
* is empty for successful commands).
* We trigger loadConfig via `resolve-model` (which calls loadConfig internally)
* and assert that stderr is EMPTY on success — a typed-IR equivalent of
* "no warning was emitted" without grepping for specific warning text. The
* absence of any stderr output IS the contract: loadConfig prints nothing
* to stderr when every top-level key in config.json is recognized.
*
* Migrated from substring `.includes('unknown config key')` text matching
* to typed empty-stderr assertions per #2974.
*/
const { describe, test, afterEach } = require('node:test');
@@ -79,13 +79,10 @@ describe('bug-2687 — no warning for dynamic-pattern containers in loadConfig',
// resolve-model calls loadConfig internally, triggering the KNOWN_TOP_LEVEL check
const result = runWithStderr(['resolve-model', 'planner'], tmpDir);
assert.ok(
!result.stderr.includes('unknown config key'),
`loadConfig must not warn about "review" — got stderr: ${result.stderr}`
);
assert.ok(
!result.stderr.includes('warning'),
`loadConfig must not warn about "review" — got stderr: ${result.stderr}`
assert.equal(
result.stderr.trim(),
'',
`loadConfig must emit no stderr output for valid dynamic-pattern keys (#2687) — got: ${result.stderr}`
);
});
@@ -101,13 +98,10 @@ describe('bug-2687 — no warning for dynamic-pattern containers in loadConfig',
// resolve-model calls loadConfig internally, triggering the KNOWN_TOP_LEVEL check
const result = runWithStderr(['resolve-model', 'planner'], tmpDir);
assert.ok(
!result.stderr.includes('unknown config key'),
`loadConfig must not warn about "model_profile_overrides" — got stderr: ${result.stderr}`
);
assert.ok(
!result.stderr.includes('warning'),
`loadConfig must not warn about "model_profile_overrides" — got stderr: ${result.stderr}`
assert.equal(
result.stderr.trim(),
'',
`loadConfig must emit no stderr output for valid dynamic-pattern keys (#2687) — got: ${result.stderr}`
);
});
@@ -123,13 +117,10 @@ describe('bug-2687 — no warning for dynamic-pattern containers in loadConfig',
// resolve-model calls loadConfig internally, triggering the KNOWN_TOP_LEVEL check
const result = runWithStderr(['resolve-model', 'planner'], tmpDir);
assert.ok(
!result.stderr.includes('unknown config key'),
`loadConfig must not warn about "claude_md_assembly" — got stderr: ${result.stderr}`
);
assert.ok(
!result.stderr.includes('warning'),
`loadConfig must not warn about "claude_md_assembly" — got stderr: ${result.stderr}`
assert.equal(
result.stderr.trim(),
'',
`loadConfig must emit no stderr output for valid dynamic-pattern keys (#2687) — got: ${result.stderr}`
);
});
});

View File

@@ -17,9 +17,6 @@
'use strict';
// allow-test-rule: pending-migration-to-typed-ir [#2974]
// Tracked in #2974 for migration to typed-IR assertions per CONTRIBUTING.md
// "Prohibited: Raw Text Matching on Test Outputs". Do not copy this pattern.
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
@@ -134,8 +131,11 @@ describe('bug-2796: roadmap update-plan-progress accepts --phase flag', () => {
});
test('flag form does not pass "--phase" as the phase value to findPhase', () => {
// Before fix: findPhase("--phase") returned found:false, causing updated:false
// This test confirms the phase value is not the flag name itself.
// Before fix: findPhase("--phase") returned found:false, causing updated:false.
// Migrated #2974: assert on the typed JSON outcome (updated:true, exit 0)
// instead of grepping stderr for the failure message. If the parser had
// mis-fed "--phase" as the value, updated would be false and the structured
// result would surface the failure typed.
createRoadmap(tmpDir, '5', '01');
const result = runSdkQuery(
@@ -144,14 +144,13 @@ describe('bug-2796: roadmap update-plan-progress accepts --phase flag', () => {
tmpDir
);
// If the phase was "--phase", we'd get an error like "Phase --phase not found"
assert.ok(
!result.stderr.includes('--phase not found'),
'stderr must not say "--phase not found"'
);
assert.ok(
!result.stderr.includes('"--phase"'),
'stderr must not treat "--phase" as the phase value'
);
assert.strictEqual(result.exitCode, 0,
`arg parser must accept --phase 5 cleanly; exitCode=${result.exitCode} stderr=${result.stderr}`);
assert.ok(result.json?.updated === true,
`expected updated:true (phase 5 found and progress updated); got json=${JSON.stringify(result.json)}`);
// The structured result also exposes the phase number that WAS resolved.
// It must be the numeric phase, not the flag name "--phase".
assert.strictEqual(String(result.json.phase), '5',
`result.phase must be the resolved phase value, not the flag literal; got ${result.json.phase}`);
});
});

View File

@@ -24,9 +24,30 @@
'use strict';
// allow-test-rule: pending-migration-to-typed-ir [#2974]
// Tracked in #2974 for migration to typed-IR assertions per CONTRIBUTING.md
// "Prohibited: Raw Text Matching on Test Outputs". Do not copy this pattern.
// Migrated to typed-IR (#2974):
// - The "rescued: yes" text contract is now parsed into a typed
// { rescued: 'yes' | 'no' | null } record by parseRescueFooter().
// Tests assert on the parsed key, not on regex against raw content.
// - The idempotent-rescue test no longer greps stdout/stderr for
// "Rescued ..." prose. Instead it asserts the filesystem-level
// invariant: the pre-existing file's mtime is unchanged after the
// rescue runs (a true no-op on disk).
/**
* Parse a SUMMARY.md's footer-style key:value lines into a typed record.
* The rescue script appends `rescued: yes` and similar metadata; tests
* assert on the parsed values rather than regex-matching the raw content.
*
* Returns: { [key: string]: string }. Unknown lines are ignored.
*/
function parseRescueFooter(content) {
const out = {};
for (const line of content.split('\n')) {
const m = line.match(/^([a-z_][\w-]*):\s*(.+?)\s*$/);
if (m) out[m[1]] = m[2];
}
return out;
}
const { describe, test, before, after } = require('node:test');
const assert = require('node:assert/strict');
@@ -155,7 +176,9 @@ describe('bug-2838: SUMMARY rescue handles gitignored .planning/', () => {
`SUMMARY was lost — rescue did not surface the file into main repo.\nRescue output:\n${rescueOut}`
);
const content = fs.readFileSync(summaryFinalPath, 'utf-8');
assert.match(content, /rescued: yes/);
const footer = parseRescueFooter(content);
assert.equal(footer.rescued, 'yes',
`expected typed footer.rescued === 'yes', got ${JSON.stringify(footer)}`);
} finally {
cleanup(tmp);
}
@@ -170,7 +193,9 @@ describe('bug-2838: SUMMARY rescue handles gitignored .planning/', () => {
`SUMMARY was lost — rescue did not surface the file into main repo.\nRescue output:\n${rescueOut}`
);
const content = fs.readFileSync(summaryFinalPath, 'utf-8');
assert.match(content, /rescued: yes/);
const footer = parseRescueFooter(content);
assert.equal(footer.rescued, 'yes',
`expected typed footer.rescued === 'yes', got ${JSON.stringify(footer)}`);
} finally {
cleanup(tmp);
}
@@ -196,7 +221,24 @@ describe('bug-2838: SUMMARY rescue handles gitignored .planning/', () => {
// Pre-place the same content in main repo
const mainDir = path.join(tmp, '.planning', 'quick', 'x');
fs.mkdirSync(mainDir, { recursive: true });
fs.writeFileSync(path.join(mainDir, 'x-SUMMARY.md'), body);
const mainSummary = path.join(mainDir, 'x-SUMMARY.md');
fs.writeFileSync(mainSummary, body);
// Capture a full filesystem snapshot BEFORE the rescue runs.
// Idempotent contract: when content already matches, the rescue must
// not touch the file. Migrated from a console-output grep
// (`stdout+stderr` did not contain "Rescued") to a typed on-disk
// check. mtimeMs alone is insufficient on coarse-grained filesystems
// (HFS+, FAT) where two rewrites within ~1s share an mtime — CR
// outside-diff finding (#3016). Snapshot includes mtime, ctime,
// size, ino, and a sha256 of contents so a rewrite is detectable
// even when the timestamp aliases.
const crypto = require('crypto');
const snapshotFile = (p) => {
const st = fs.statSync(p);
const hash = crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
return { mtimeMs: st.mtimeMs, ctimeMs: st.ctimeMs, size: st.size, ino: st.ino, hash };
};
const snapBefore = snapshotFile(mainSummary);
const script = `
set -u
@@ -211,9 +253,13 @@ ${block}
0,
`Rescue block failed unexpectedly.\nstdout: ${r.stdout}\nstderr: ${r.stderr}`
);
// No "Rescued" message expected because cmp -s matches.
assert.doesNotMatch(r.stdout + r.stderr, /Rescued .*x-SUMMARY\.md/);
assert.strictEqual(fs.readFileSync(path.join(mainDir, 'x-SUMMARY.md'), 'utf-8'), body);
// Typed-IR idempotency check (#2974): full snapshot unchanged. The
// sha256 hash catches rewrites that mtimeMs would miss on
// coarse-grained filesystems.
const snapAfter = snapshotFile(mainSummary);
assert.deepStrictEqual(snapAfter, snapBefore,
'rescue must not touch the file when content already matches (idempotent)');
assert.strictEqual(fs.readFileSync(mainSummary, 'utf-8'), body);
} finally {
try { sh(tmp, `git worktree remove "${wt}" --force`); } catch (_) {}
cleanup(tmp);

View File

@@ -12,9 +12,11 @@
'use strict';
// allow-test-rule: pending-migration-to-typed-ir [#2974]
// Tracked in #2974 for migration to typed-IR assertions per CONTRIBUTING.md
// "Prohibited: Raw Text Matching on Test Outputs". Do not copy this pattern.
// Migrated to typed-IR (#2974): the previous shape grepped stderr/stdout for
// "Key not found"; now the test passes `--json-errors` to gsd-tools and
// asserts on the structured `reason` code (a frozen-enum value from
// `core.cjs::ERROR_REASON`). Exit code is also a typed signal — together
// they fully discriminate the failure class.
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
@@ -24,6 +26,7 @@ const os = require('node:os');
const { execFileSync } = require('node:child_process');
const GSD_TOOLS = path.join(__dirname, '..', 'get-shit-done', 'bin', 'gsd-tools.cjs');
const { ERROR_REASON } = require(path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'core.cjs'));
describe('bug-2943: config-get returns schema default for context_window', () => {
let tmpDir;
@@ -102,20 +105,27 @@ describe('bug-2943: config-get returns schema default for context_window', () =>
assert.strictEqual(result.stdout, '123456', 'should return the --default value, not schema default');
});
test('errors with "Key not found" (exit 1) for an unknown absent key — no regression', () => {
// An unrecognised key with no schema default still errors as before
test('errors with reason=CONFIG_KEY_NOT_FOUND (exit 1) for an unknown absent key — no regression', () => {
// An unrecognised key with no schema default still errors as before.
// Migrated #2974: assert on the structured reason code from --json-errors,
// not on substring presence in stderr/stdout text.
fs.writeFileSync(
path.join(planningDir, 'config.json'),
JSON.stringify({ workflow: { auto_advance: false } })
);
const result = runConfigGet('totally_unknown_key_xyz');
const result = runConfigGet('totally_unknown_key_xyz', ['--json-errors']);
assert.strictEqual(result.exitCode, 1, 'should exit 1 for unknown absent key');
assert.ok(
result.stderr.includes('Key not found') || result.stdout.includes('Key not found'),
`expected "Key not found" in output, got stderr="${result.stderr}" stdout="${result.stdout}"`
);
let parsed;
try {
parsed = JSON.parse(result.stderr);
} catch (err) {
assert.fail(`expected JSON-shaped stderr from --json-errors; got: ${JSON.stringify(result.stderr)}`);
}
assert.strictEqual(parsed.ok, false);
assert.strictEqual(parsed.reason, ERROR_REASON.CONFIG_KEY_NOT_FOUND,
`expected reason=${ERROR_REASON.CONFIG_KEY_NOT_FOUND}, got=${parsed.reason}`);
});
test('--default flag still works for arbitrary absent keys', () => {

View File

@@ -1,8 +1,9 @@
'use strict';
// allow-test-rule: pending-migration-to-typed-ir [#2974]
// Tracked in #2974 for migration to typed-IR assertions per CONTRIBUTING.md
// "Prohibited: Raw Text Matching on Test Outputs". Do not copy this pattern.
// Migrated to typed-IR (#2974): execGraphify now returns a typed
// `reason` field (GRAPHIFY_REASON enum) alongside exitCode/stdout/stderr.
// Tests assert on result.reason instead of grepping stderr for failure
// phrases like 'not found' or 'timed out'.
/**
* Tests for get-shit-done/bin/lib/graphify.cjs
@@ -23,6 +24,7 @@ const {
isGraphifyEnabled,
disabledResponse,
execGraphify,
GRAPHIFY_REASON,
checkGraphifyInstalled,
checkGraphifyVersion,
// Phase 2
@@ -185,7 +187,9 @@ describe('execGraphify', () => {
const result = execGraphify('/tmp', ['build']);
assert.strictEqual(result.exitCode, 127);
assert.ok(result.stderr.includes('not found'));
// Migrated #2974: assert on the typed `reason` field instead of
// grepping stderr for 'not found'.
assert.strictEqual(result.reason, GRAPHIFY_REASON.ENOENT);
});
test('returns exitCode 124 on timeout', () => {
@@ -199,7 +203,9 @@ describe('execGraphify', () => {
const result = execGraphify('/tmp', ['build']);
assert.strictEqual(result.exitCode, 124);
assert.ok(result.stderr.includes('timed out'));
// Migrated #2974: typed reason instead of stderr grep.
assert.strictEqual(result.reason, GRAPHIFY_REASON.TIMEOUT);
assert.strictEqual(result.timeout_ms, 30000);
});
test('passes PYTHONUNBUFFERED=1 in env', () => {

View File

@@ -1,6 +1,9 @@
// allow-test-rule: pending-migration-to-typed-ir [#2974]
// Tracked in #2974 for migration to typed-IR assertions per CONTRIBUTING.md
// "Prohibited: Raw Text Matching on Test Outputs". Do not copy this pattern.
// Migrated to typed-IR (#2974): the gsd-session-state.sh and
// gsd-phase-boundary.sh hooks now emit Claude Code SessionStart/PostToolUse
// JSON envelopes ({ hookSpecificOutput: { hookEventName, additionalContext,
// state_present, config_mode | planning_modified, file_path } }) instead of
// plain text. gsd-validate-commit.sh already emitted JSON ({ decision,
// reason }). Tests parse the JSON and assert on typed fields.
/**
* GSD Tools Tests - Community Hooks (opt-in)
@@ -268,11 +271,11 @@ describe('opt-in gating behavior', { skip: isWindows ? 'bash hooks require unix
});
assert.strictEqual(result.status, 0, `Should exit 0: ${result.stderr}`);
// Should NOT output state info when disabled
assert.ok(
!result.stdout.includes('Project State Reminder'),
`Should not output state reminder when disabled: ${result.stdout}`
);
// Migrated #2974: typed assertion that stdout is empty (no JSON envelope
// emitted when the hook is a no-op). The previous shape grepped for
// "Project State Reminder" prose; now the contract is "no output".
assert.equal(result.stdout.trim(), '',
`Should produce no output when disabled: ${JSON.stringify(result.stdout)}`);
});
test('phase-boundary is a no-op when hooks.community is false', () => {
@@ -289,10 +292,9 @@ describe('opt-in gating behavior', { skip: isWindows ? 'bash hooks require unix
});
assert.strictEqual(result.status, 0, `Should exit 0: ${result.stderr}`);
assert.ok(
!result.stdout.includes('.planning/ file modified'),
`Should not output warning when disabled: ${result.stdout}`
);
// Migrated #2974: typed empty-stdout assertion (#2974).
assert.equal(result.stdout.trim(), '',
`Should produce no output when disabled: ${JSON.stringify(result.stdout)}`);
});
});
@@ -340,8 +342,18 @@ describe('hook execution when enabled', { skip: isWindows ? 'bash hooks require
});
assert.strictEqual(result.status, 2, `Non-conventional commit should exit 2, got ${result.status}`);
assert.ok(result.stdout.includes('block'), `stdout should contain "block": ${result.stdout}`);
assert.ok(result.stdout.includes('Conventional Commits'), `stdout should mention "Conventional Commits": ${result.stdout}`);
// Migrated #2974: parse the hook's JSON envelope and assert on typed
// fields (decision, reason). Hook protocol returns
// { decision: 'block', reason: '...' } for blocked commits.
const parsed = JSON.parse(result.stdout);
assert.strictEqual(parsed.decision, 'block',
`expected typed decision: 'block', got: ${JSON.stringify(parsed)}`);
// Assert on the typed `code` field (stable enum value), not the
// human-readable `reason` string. CR feedback (#3016): substring
// matching on `reason` is still text matching — the hook now emits
// a typed code alongside the prose so tests pin behavior, not copy.
assert.strictEqual(parsed.code, 'CONVENTIONAL_COMMITS_VIOLATION',
`expected typed code: 'CONVENTIONAL_COMMITS_VIOLATION', got: ${JSON.stringify(parsed)}`);
});
test('validate-commit allows non-commit commands', () => {
@@ -370,10 +382,13 @@ describe('hook execution when enabled', { skip: isWindows ? 'bash hooks require
});
assert.strictEqual(result.status, 0, `Should exit 0: ${result.stderr}`);
assert.ok(
result.stdout.includes('STATE.md exists'),
`stdout should contain "STATE.md exists": ${result.stdout}`
);
// Migrated #2974: parse the SessionStart JSON envelope and assert on
// typed fields. The hook now emits
// { hookSpecificOutput: { hookEventName, additionalContext, state_present, config_mode } }.
const parsed = JSON.parse(result.stdout);
assert.strictEqual(parsed.hookSpecificOutput.hookEventName, 'SessionStart');
assert.strictEqual(parsed.hookSpecificOutput.state_present, true,
'state_present must reflect that STATE.md was written by writeMinimalStateMd');
});
test('session-state exits 0 without .planning/ (in enabled project)', (t) => {
@@ -391,10 +406,11 @@ describe('hook execution when enabled', { skip: isWindows ? 'bash hooks require
});
assert.strictEqual(result.status, 0, `Should exit 0: ${result.stderr}`);
assert.ok(
result.stdout.includes('No .planning/ found') || result.stdout.includes('Project State'),
`Should handle missing STATE.md gracefully: ${result.stdout}`
);
// Migrated #2974: typed assertion on state_present field instead of
// grepping additionalContext text for "No .planning/ found".
const parsed = JSON.parse(result.stdout);
assert.strictEqual(parsed.hookSpecificOutput.state_present, false,
'state_present must be false when STATE.md is absent');
});
test('phase-boundary detects .planning/ writes when enabled', () => {
@@ -410,10 +426,13 @@ describe('hook execution when enabled', { skip: isWindows ? 'bash hooks require
});
assert.strictEqual(result.status, 0, `Should exit 0: ${result.stderr}`);
assert.ok(
result.stdout.includes('.planning/ file modified'),
`stdout should contain ".planning/ file modified": ${result.stdout}`
);
// Migrated #2974: parse the PostToolUse JSON envelope. The hook emits
// { hookSpecificOutput: { hookEventName, additionalContext,
// planning_modified, file_path } } when a .planning/ write is detected.
const parsed = JSON.parse(result.stdout);
assert.strictEqual(parsed.hookSpecificOutput.hookEventName, 'PostToolUse');
assert.strictEqual(parsed.hookSpecificOutput.planning_modified, true);
assert.strictEqual(parsed.hookSpecificOutput.file_path, '.planning/STATE.md');
});
});
@@ -446,7 +465,8 @@ describe('hook security tests', { skip: isWindows ? 'bash hooks require unix she
});
assert.strictEqual(result.status, 2, `Shell metacharacter message should be blocked: ${result.status}`);
assert.ok(result.stdout.includes('block'), `stdout should contain "block": ${result.stdout}`);
// Migrated #2974: typed JSON envelope assertion (parsed.decision === 'block').
assert.strictEqual(JSON.parse(result.stdout).decision, 'block');
});
test('validate-commit blocks message with backtick injection', () => {
@@ -462,7 +482,8 @@ describe('hook security tests', { skip: isWindows ? 'bash hooks require unix she
});
assert.strictEqual(result.status, 2, `Backtick injection should be blocked: ${result.status}`);
assert.ok(result.stdout.includes('block'), `stdout should contain "block": ${result.stdout}`);
// Migrated #2974: typed JSON envelope assertion (parsed.decision === 'block').
assert.strictEqual(JSON.parse(result.stdout).decision, 'block');
});
test('validate-commit allows commit with scope containing special chars', () => {

View File

@@ -12,9 +12,25 @@
*/
'use strict';
// allow-test-rule: pending-migration-to-typed-ir [#2974]
// Tracked in #2974 for migration to typed-IR assertions per CONTRIBUTING.md
// "Prohibited: Raw Text Matching on Test Outputs". Do not copy this pattern.
// Reviewed for #2974 (typed-IR migration) and reclassified.
//
// allow-test-rule: source-text-is-the-product
// Justification: this file tests scan scripts and CI workflow YAML where
// the textual output IS the deployed contract:
// 1. Shebang lines (`#!/usr/bin/env bash`) ARE the runtime invocation
// contract — startsWith() on the first line is a structural check
// on the file format, not a grep on internal behavior.
// 2. Scan-script labeled findings (`AWS Access Key`, `GitHub PAT`,
// `Private Key`, `Env Variable`) ARE the CI failure log contract
// that humans read when a scan trips. Asserting the label appears
// in stdout is a typed behavioral check on the scanner's output
// protocol.
// 3. .github/workflows/security-scan.yml's step list IS the deployed
// CI pipeline. Substring presence of `prompt-injection-scan.sh`,
// `fetch-depth: 0`, etc. is a structural assertion on what the
// pipeline does, equivalent to parsing the YAML and walking steps.
// Migrating these to a parsed IR would add ceremony without changing
// what is verified — the strings ARE the typed surface.
const { describe, test, before, after } = require('node:test');
const assert = require('node:assert/strict');