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

@@ -0,0 +1,5 @@
---
type: Changed
pr: 2974
---
Migrated 8 test files from raw text matching (`stdout.includes(...)`, `assert.match(stderr, ...)`) to typed-IR assertions per CONTRIBUTING.md. Adds shared `ERROR_REASON` enum and `--json-errors` flag in `core.cjs`, typed `GRAPHIFY_REASON` in `graphify.cjs`, pure `buildSdkFailFastReport()` IR builder in `bin/install.js`, and Claude Code JSON envelope output (`hookSpecificOutput` with typed fields) for `gsd-session-state.sh` and `gsd-phase-boundary.sh`. Tests now assert on structured fields (`reason`, `context`, `state_present`, `planning_modified`, etc.) instead of substring matching. See #2974.

View File

@@ -8944,6 +8944,85 @@ function classifySdkInstall(sdkDir) {
return { mode, npxCache, readOnly };
}
/**
* #2974: pure builder for the SDK fail-fast report. Returns a structured IR
* with everything the renderer needs PLUS everything tests need to assert
* on. Tests can call `buildSdkFailFastReport(sdkDir, sdkCliPath)` directly
* and assert on `report.reason`, `report.context`, `report.fix_command`
* etc. without intercepting console.error or matching against rendered
* text.
*
* Shape (frozen contract — extending requires a new test):
* {
* ok: false,
* reason: 'sdk_fail_fast', // ERROR_REASON.SDK_FAIL_FAST
* context: 'npx-cache' | 'tarball' | 'dev-clone',
* missing_path: '<path>/sdk/dist/cli.js',
* missing_artifact: 'sdk/dist',
* fix_command: 'npm install -g get-shit-done-cc@latest' | 'cd sdk && npm install && npm run build',
* attempted_nested_install: false, // contract: never true
* }
*/
function buildSdkFailFastReport(sdkDir, sdkCliPath) {
const ctx = classifySdkInstall(sdkDir);
let context, fix_command;
if (ctx.mode === 'tarball') {
context = ctx.npxCache ? 'npx-cache' : 'tarball';
fix_command = 'npm install -g get-shit-done-cc@latest';
} else {
context = 'dev-clone';
fix_command = 'cd sdk && npm install && npm run build';
}
return {
ok: false,
reason: 'sdk_fail_fast',
context,
missing_path: sdkCliPath,
missing_artifact: 'sdk/dist',
fix_command,
attempted_nested_install: false,
};
}
/**
* Renderer for the structured fail-fast report. Text formatting only —
* tests never call this. Splits the IR fields back into the same human-
* readable lines the previous shape produced.
*/
function renderSdkFailFastReport(ir) {
const bar = '━'.repeat(72);
const redBold = `${red}${bold}`;
console.error('');
console.error(`${redBold}${bar}${reset}`);
console.error(`${redBold} ✗ GSD SDK dist not found — /gsd-* commands will not work${reset}`);
console.error(`${redBold}${bar}${reset}`);
console.error(` ${red}Reason:${reset} ${ir.missing_artifact}/cli.js not found at ${ir.missing_path}`);
console.error('');
if (ir.context === 'npx-cache') {
console.error(` Detected read-only npx cache install (${dim}${path.dirname(ir.missing_path).replace(/\/dist$/, '')}${reset}).`);
console.error(` The installer will ${bold}not${reset} attempt \`npm install\` inside the npx cache.`);
console.error('');
console.error(` Fix: install a version that ships sdk/dist/ globally:`);
console.error(` ${cyan}${ir.fix_command}${reset}`);
console.error(` Or, if you prefer a one-shot run, clear the npx cache first:`);
console.error(` ${cyan}npx --yes get-shit-done-cc@latest${reset}`);
console.error(` Or build from source (git clone):`);
console.error(` ${cyan}git clone https://github.com/gsd-build/get-shit-done && cd get-shit-done/sdk && npm install && npm run build${reset}`);
} else if (ir.context === 'tarball') {
console.error(` The published tarball appears to be missing sdk/dist/ (see #2647).`);
console.error('');
console.error(` Fix: install a version that ships sdk/dist/ globally:`);
console.error(` ${cyan}${ir.fix_command}${reset}`);
console.error(` Or build from source (git clone):`);
console.error(` ${cyan}git clone https://github.com/gsd-build/get-shit-done && cd get-shit-done/sdk && npm install && npm run build${reset}`);
} else {
console.error(` Running from a git clone — build the SDK first:`);
console.error(` ${cyan}${ir.fix_command}${reset}`);
}
console.error(`${redBold}${bar}${reset}`);
console.error('');
}
function installSdkIfNeeded(opts) {
opts = opts || {};
if (hasNoSdk && !opts.sdkDir) {
@@ -8971,44 +9050,8 @@ function installSdkIfNeeded(opts) {
}
if (!fs.existsSync(sdkCliPath)) {
const ctx = classifySdkInstall(sdkDir);
const bar = '━'.repeat(72);
const redBold = `${red}${bold}`;
console.error('');
console.error(`${redBold}${bar}${reset}`);
console.error(`${redBold} ✗ GSD SDK dist not found — /gsd-* commands will not work${reset}`);
console.error(`${redBold}${bar}${reset}`);
console.error(` ${red}Reason:${reset} sdk/dist/cli.js not found at ${sdkCliPath}`);
console.error('');
if (ctx.mode === 'tarball') {
// User install (including `npx get-shit-done-cc@latest`, which stages
// a read-only tarball under the npx cache). The sdk/dist/ artifact
// should ship in the published tarball. If it's missing, the only
// sane fix from the user's side is a fresh global install of a
// version that includes dist/. Do NOT attempt a nested `npm install`
// inside the (read-only) npx cache — that's the #2649 failure mode.
if (ctx.npxCache) {
console.error(` Detected read-only npx cache install (${dim}${sdkDir}${reset}).`);
console.error(` The installer will ${bold}not${reset} attempt \`npm install\` inside the npx cache.`);
console.error('');
} else {
console.error(` The published tarball appears to be missing sdk/dist/ (see #2647).`);
console.error('');
}
console.error(` Fix: install a version that ships sdk/dist/ globally:`);
console.error(` ${cyan}npm install -g get-shit-done-cc@latest${reset}`);
console.error(` Or, if you prefer a one-shot run, clear the npx cache first:`);
console.error(` ${cyan}npx --yes get-shit-done-cc@latest${reset}`);
console.error(` Or build from source (git clone):`);
console.error(` ${cyan}git clone https://github.com/gsd-build/get-shit-done && cd get-shit-done/sdk && npm install && npm run build${reset}`);
} else {
// Dev clone: keep the existing build-from-source hint.
console.error(` Running from a git clone — build the SDK first:`);
console.error(` ${cyan}cd sdk && npm install && npm run build${reset}`);
}
console.error(`${redBold}${bar}${reset}`);
console.error('');
const ir = buildSdkFailFastReport(sdkDir, sdkCliPath);
renderSdkFailFastReport(ir);
process.exit(1);
}
@@ -9370,6 +9413,8 @@ if (process.env.GSD_TEST_MODE) {
install,
uninstall,
installSdkIfNeeded,
buildSdkFailFastReport,
renderSdkFailFastReport,
classifySdkInstall,
convertClaudeCommandToCodexSkill,
convertClaudeToOpencodeFrontmatter,

View File

@@ -305,6 +305,18 @@ async function main() {
const raw = rawIndex !== -1;
if (rawIndex !== -1) args.splice(rawIndex, 1);
// --json-errors: when present, error() emits structured JSON to stderr
// ({ ok: false, reason: <ERROR_REASON code>, message }) instead of plain
// "Error: <text>". Lets test suites assert on typed reason codes per the
// CONTRIBUTING.md "Prohibited: Raw Text Matching on Test Outputs" rule
// (#2974). Default off — human operators see the original plain-text
// diagnostic.
const jsonErrorsIdx = args.indexOf('--json-errors');
if (jsonErrorsIdx !== -1) {
core.setJsonErrorMode(true);
args.splice(jsonErrorsIdx, 1);
}
// --pick <name>: extract a single field from JSON output (replaces jq dependency).
// Supports dot-notation (e.g., --pick workflow.research) and bracket notation
// for arrays (e.g., --pick directories[-1]).

View File

@@ -4,7 +4,7 @@
const fs = require('fs');
const path = require('path');
const { output, error, CONFIG_DEFAULTS, atomicWriteFileSync } = require('./core.cjs');
const { output, error, ERROR_REASON, CONFIG_DEFAULTS, atomicWriteFileSync } = require('./core.cjs');
const { planningDir, withPlanningLock } = require('./planning-workspace.cjs');
const {
VALID_PROFILES,
@@ -33,7 +33,7 @@ const CONFIG_KEY_SUGGESTIONS = {
function validateKnownConfigKeyPath(keyPath) {
const suggested = CONFIG_KEY_SUGGESTIONS[keyPath];
if (suggested) {
error(`Unknown config key: ${keyPath}. Did you mean ${suggested}?`);
error(`Unknown config key: ${keyPath}. Did you mean ${suggested}?`, ERROR_REASON.CONFIG_INVALID_KEY);
}
}
@@ -278,7 +278,7 @@ function setConfigValue(cwd, keyPath, parsedValue) {
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
}
} catch (err) {
error('Failed to read config.json: ' + err.message);
error('Failed to read config.json: ' + err.message, ERROR_REASON.CONFIG_PARSE_FAILED);
}
// Set nested value using dot notation (e.g., "workflow.research")
@@ -319,7 +319,7 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
validateKnownConfigKeyPath(keyPath);
if (!isValidConfigKey(keyPath)) {
error(`Unknown config key: "${keyPath}". Valid keys: ${[...VALID_CONFIG_KEYS].sort().join(', ')}, agent_skills.<agent-type>, features.<feature_name>`);
error(`Unknown config key: "${keyPath}". Valid keys: ${[...VALID_CONFIG_KEYS].sort().join(', ')}, agent_skills.<agent-type>, features.<feature_name>`, ERROR_REASON.CONFIG_INVALID_KEY);
}
// Parse value (handle booleans, numbers, and JSON arrays/objects)
@@ -402,11 +402,11 @@ function cmdConfigGet(cwd, keyPath, raw, defaultValue) {
output(defaultValue, raw, String(defaultValue));
return;
} else {
error('No config.json found at ' + configPath);
error('No config.json found at ' + configPath, ERROR_REASON.CONFIG_NO_FILE);
}
} catch (err) {
if (err.message.startsWith('No config.json')) throw err;
error('Failed to read config.json: ' + err.message);
error('Failed to read config.json: ' + err.message, ERROR_REASON.CONFIG_PARSE_FAILED);
}
// Traverse dot-notation path (e.g., "workflow.auto_advance")
@@ -420,7 +420,7 @@ function cmdConfigGet(cwd, keyPath, raw, defaultValue) {
output(def, raw, String(def));
return;
}
error(`Key not found: ${keyPath}`);
error(`Key not found: ${keyPath}`, ERROR_REASON.CONFIG_KEY_NOT_FOUND);
}
current = current[key];
}
@@ -432,7 +432,7 @@ function cmdConfigGet(cwd, keyPath, raw, defaultValue) {
output(def, raw, String(def));
return;
}
error(`Key not found: ${keyPath}`);
error(`Key not found: ${keyPath}`, ERROR_REASON.CONFIG_KEY_NOT_FOUND);
}
// Never echo plaintext for sensitive keys via config-get. Plaintext lives

View File

@@ -201,8 +201,68 @@ function output(result, raw, rawValue) {
fs.writeSync(1, data);
}
function error(message) {
fs.writeSync(2, 'Error: ' + message + '\n');
/**
* Frozen enum of typed reason codes used by error() for structured errors.
* Each subcommand contributes its own codes; the enum exists so tests can
* assert against typed values instead of grepping stderr (#2974).
*
* Adding a new code:
* - Pick a snake_case lowercase value (the JSON wire form)
* - Group by subsystem prefix (CONFIG_*, SDK_*, etc)
* - Pass it to error(msg, ERROR_REASON.NEW_CODE) at the call site
*/
const ERROR_REASON = Object.freeze({
// config-get / config-set
CONFIG_KEY_NOT_FOUND: 'config_key_not_found',
CONFIG_NO_FILE: 'config_no_file',
CONFIG_PARSE_FAILED: 'config_parse_failed',
CONFIG_INVALID_KEY: 'config_invalid_key',
// SDK / gsd-tools dispatch
SDK_FAIL_FAST: 'sdk_fail_fast',
SDK_UNKNOWN_COMMAND: 'sdk_unknown_command',
SDK_MISSING_ARG: 'sdk_missing_arg',
// workflow / phase
PHASE_NOT_FOUND: 'phase_not_found',
SUMMARY_NO_PLANNING: 'summary_no_planning',
// graphify
GRAPHIFY_NO_GRAPH: 'graphify_no_graph',
GRAPHIFY_INVALID_QUERY: 'graphify_invalid_query',
// hooks
HOOKS_OPT_OUT: 'hooks_opt_out',
// security-scan
SECURITY_SCAN_FAILED: 'security_scan_failed',
// generic
USAGE: 'usage',
UNKNOWN: 'unknown',
});
/**
* Process-level flag: when true, error() emits structured JSON to stderr
* instead of plain "Error: <message>" text. Set by gsd-tools.cjs when the
* CLI is invoked with `--json-errors`. Tests opt in to typed-IR error
* assertions by passing that flag and parsing the JSON.
*
* Default off so existing callers and human operators keep their plain-text
* diagnostics. The structured form is opt-in for tooling and tests (#2974).
*/
let _jsonErrorMode = false;
function setJsonErrorMode(v) { _jsonErrorMode = !!v; }
function getJsonErrorMode() { return _jsonErrorMode; }
/**
* Emit an error and exit. When the second argument is provided it must be
* a value from ERROR_REASON; tests can assert on `result.reason`. When the
* process is in JSON-error mode, stderr receives `{ ok: false, reason,
* message }` so callers can parse it; otherwise stderr keeps the plain
* text form for human operators.
*/
function error(message, reason = ERROR_REASON.UNKNOWN) {
if (_jsonErrorMode) {
const payload = JSON.stringify({ ok: false, reason, message }) + '\n';
fs.writeSync(2, payload);
} else {
fs.writeSync(2, 'Error: ' + message + '\n');
}
process.exit(1);
}
@@ -1816,6 +1876,9 @@ function timeAgo(date) {
module.exports = {
output,
error,
ERROR_REASON,
setJsonErrorMode,
getJsonErrorMode,
safeReadFile,
loadConfig,
isGitIgnored,

View File

@@ -45,6 +45,17 @@ function disabledResponse() {
* @param {{ timeout?: number }} [options={}] - Options (timeout in ms, default 30000)
* @returns {{ exitCode: number, stdout: string, stderr: string }}
*/
/**
* Frozen enum of typed reason codes for execGraphify failures (#2974).
* Tests assert on result.reason instead of grepping stderr text.
*/
const GRAPHIFY_REASON = Object.freeze({
OK: 'ok',
ENOENT: 'graphify_not_found',
TIMEOUT: 'graphify_timed_out',
EXIT_NONZERO: 'graphify_exit_nonzero',
});
function execGraphify(cwd, args, options = {}) {
const timeout = options.timeout ?? 30000;
const result = childProcess.spawnSync('graphify', args, {
@@ -57,7 +68,12 @@ function execGraphify(cwd, args, options = {}) {
// ENOENT -- graphify binary not found on PATH
if (result.error && result.error.code === 'ENOENT') {
return { exitCode: 127, stdout: '', stderr: 'graphify not found on PATH' };
return {
exitCode: 127,
stdout: '',
stderr: 'graphify not found on PATH',
reason: GRAPHIFY_REASON.ENOENT,
};
}
// Timeout -- subprocess killed via SIGTERM
@@ -66,13 +82,17 @@ function execGraphify(cwd, args, options = {}) {
exitCode: 124,
stdout: (result.stdout ?? '').toString().trim(),
stderr: 'graphify timed out after ' + timeout + 'ms',
reason: GRAPHIFY_REASON.TIMEOUT,
timeout_ms: timeout,
};
}
const exitCode = result.status ?? 1;
return {
exitCode: result.status ?? 1,
exitCode,
stdout: (result.stdout ?? '').toString().trim(),
stderr: (result.stderr ?? '').toString().trim(),
reason: exitCode === 0 ? GRAPHIFY_REASON.OK : GRAPHIFY_REASON.EXIT_NONZERO,
};
}
@@ -504,6 +524,7 @@ module.exports = {
disabledResponse,
// Subprocess
execGraphify,
GRAPHIFY_REASON,
// Presence and version
checkGraphifyInstalled,
checkGraphifyVersion,

View File

@@ -20,9 +20,28 @@ INPUT=$(cat)
# Extract file_path from JSON using Node (handles escaping correctly)
FILE=$(echo "$INPUT" | node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{process.stdout.write(JSON.parse(d).tool_input?.file_path||'')}catch{}})" 2>/dev/null)
# Emit a structured JSON envelope (#2974). additionalContext carries the
# user-visible reminder text; the typed `planning_modified` boolean and
# `file_path` let tests assert on the structured contract without grepping.
PLANNING_MODIFIED="false"
if [[ "$FILE" == *.planning/* ]] || [[ "$FILE" == .planning/* ]]; then
echo ".planning/ file modified: $FILE"
echo "Check: Should STATE.md be updated to reflect this change?"
PLANNING_MODIFIED="true"
fi
if [ "$PLANNING_MODIFIED" = "true" ]; then
node -e '
const file = process.argv[1];
const additionalContext = ".planning/ file modified: " + file + "\n" +
"Check: Should STATE.md be updated to reflect this change?";
process.stdout.write(JSON.stringify({
hookSpecificOutput: {
hookEventName: "PostToolUse",
additionalContext,
planning_modified: true,
file_path: file,
},
}));
' "$FILE"
fi
exit 0

View File

@@ -14,21 +14,46 @@ else
exit 0
fi
echo '## Project State Reminder'
echo ''
# Build the additionalContext text and emit it as a structured JSON
# envelope per the Claude Code SessionStart hook protocol (#2974). Tests
# parse the JSON and assert on typed fields (state_present: bool,
# config_mode: string, etc) rather than substring-matching free-form text.
STATE_PRESENT="false"
STATE_HEAD=""
if [ -f .planning/STATE.md ]; then
echo 'STATE.md exists - check for blockers and current phase.'
head -20 .planning/STATE.md
else
echo 'No .planning/ found - suggest /gsd-new-project if starting new work.'
STATE_PRESENT="true"
STATE_HEAD=$(head -20 .planning/STATE.md)
fi
echo ''
CONFIG_MODE="unknown"
if [ -f .planning/config.json ]; then
MODE=$(grep -o '"mode"[[:space:]]*:[[:space:]]*"[^"]*"' .planning/config.json 2>/dev/null || echo '"mode": "unknown"')
echo "Config: $MODE"
CONFIG_MODE=$(node -e "try{const c=require('./.planning/config.json');process.stdout.write(String(c.mode||'unknown'))}catch{process.stdout.write('unknown')}" 2>/dev/null)
fi
# Use Node for JSON encoding so embedded newlines/quotes are escaped correctly.
# additionalContext is the text Claude Code injects at session start; the
# typed fields (state_present, config_mode) let tests assert on the
# structured contract without grepping the prose.
node -e '
const [statePresent, stateHead, configMode] = process.argv.slice(1);
const headerLines = ["## Project State Reminder", ""];
if (statePresent === "true") {
headerLines.push("STATE.md exists - check for blockers and current phase.");
if (stateHead) headerLines.push(stateHead);
} else {
headerLines.push("No .planning/ found - suggest /gsd-new-project if starting new work.");
}
headerLines.push("");
headerLines.push("Config: \"mode\": \"" + configMode + "\"");
const additionalContext = headerLines.join("\n");
process.stdout.write(JSON.stringify({
hookSpecificOutput: {
hookEventName: "SessionStart",
additionalContext,
state_present: statePresent === "true",
config_mode: configMode,
},
}));
' "$STATE_PRESENT" "$STATE_HEAD" "$CONFIG_MODE"
exit 0

View File

@@ -35,11 +35,13 @@ if [[ "$CMD" =~ ^git[[:space:]]+commit ]]; then
SUBJECT=$(echo "$MSG" | head -1)
# Validate Conventional Commits format
if ! [[ "$SUBJECT" =~ ^(feat|fix|docs|style|refactor|perf|test|build|ci|chore)(\(.+\))?:[[:space:]].+ ]]; then
echo '{"decision": "block", "reason": "Commit message must follow Conventional Commits: <type>(<scope>): <subject>. Valid types: feat, fix, docs, style, refactor, perf, test, build, ci, chore. Subject must be <=72 chars, lowercase, imperative mood, no trailing period."}'
# Emit a typed `code` field alongside `reason` (#2974). Tests assert
# on the stable code string; the reason is the human-readable copy.
echo '{"decision": "block", "code": "CONVENTIONAL_COMMITS_VIOLATION", "reason": "Commit message must follow Conventional Commits: <type>(<scope>): <subject>. Valid types: feat, fix, docs, style, refactor, perf, test, build, ci, chore. Subject must be <=72 chars, lowercase, imperative mood, no trailing period."}'
exit 2
fi
if [ ${#SUBJECT} -gt 72 ]; then
echo '{"decision": "block", "reason": "Commit subject must be 72 characters or less."}'
echo '{"decision": "block", "code": "COMMIT_SUBJECT_TOO_LONG", "reason": "Commit subject must be 72 characters or less."}'
exit 2
fi
fi

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');