* 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:
@@ -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 : [];
|
||||
|
||||
@@ -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}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user