* 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).
269 lines
11 KiB
JavaScript
269 lines
11 KiB
JavaScript
/**
|
|
* Regression tests for #2838: SUMMARY rescue silently fails when .planning/
|
|
* is gitignored.
|
|
*
|
|
* The pre-fix rescue used `git ls-files --modified --others --exclude-standard`
|
|
* to detect uncommitted SUMMARY.md files. When projects gitignore .planning/
|
|
* (a common policy), --exclude-standard filters out the very files the rescue
|
|
* was meant to save, producing an empty result and skipping the rescue branch.
|
|
* The next line `git worktree remove --force` then permanently deleted the
|
|
* SUMMARY.
|
|
*
|
|
* The fix replaces git ls-files with a filesystem-level `find` + `cp` rescue
|
|
* that bypasses gitignore entirely.
|
|
*
|
|
* This test file:
|
|
* 1. Extracts the rescue block from each workflow file (parsed structurally
|
|
* by locating the labeled comment + closing fence — not free-form regex
|
|
* over file contents).
|
|
* 2. Runs the extracted block against a real temp repo whose .planning/
|
|
* directory is gitignored.
|
|
* 3. Asserts the SUMMARY is rescued into the main repo before worktree
|
|
* removal.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
// 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');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const os = require('os');
|
|
const { execFileSync, spawnSync } = require('child_process');
|
|
|
|
const REPO_ROOT = path.join(__dirname, '..');
|
|
const EXECUTE_PHASE_PATH = path.join(REPO_ROOT, 'get-shit-done', 'workflows', 'execute-phase.md');
|
|
const QUICK_PATH = path.join(REPO_ROOT, 'get-shit-done', 'workflows', 'quick.md');
|
|
|
|
/**
|
|
* Extract the rescue block (the bash lines that detect+rescue the
|
|
* uncommitted SUMMARY.md). We locate it by:
|
|
* - Finding the line that contains "Safety net" AND "SUMMARY"
|
|
* - Reading forward until the indent drops back to the surrounding level
|
|
* OR we hit a blank line followed by a non-comment, non-rescue line.
|
|
*
|
|
* To keep this robust, we scan from the safety-net comment forward until we
|
|
* either reach `done` (new fix) or `fi` (old fix) followed by a blank line.
|
|
*/
|
|
function extractRescueBlock(filePath) {
|
|
const lines = fs.readFileSync(filePath, 'utf-8').split('\n');
|
|
let startIdx = -1;
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (/Safety net/.test(lines[i]) && /SUMMARY/.test(lines[i])) {
|
|
startIdx = i;
|
|
break;
|
|
}
|
|
}
|
|
assert.notStrictEqual(startIdx, -1, `Could not find Safety net SUMMARY rescue block in ${filePath}`);
|
|
|
|
// Capture from comment through the terminator. The new fix ends with
|
|
// `done < <(find ... )`. The old fix ended with `fi` followed by a blank
|
|
// line. We accumulate until we find one of those terminators; if we see
|
|
// `done < <(find` we always stop there (it can only appear after `fi`).
|
|
const collected = [];
|
|
let sawFi = false;
|
|
for (let i = startIdx; i < lines.length; i++) {
|
|
collected.push(lines[i]);
|
|
const trimmed = lines[i].trim();
|
|
if (/^done\s*<\s*<\(find/.test(trimmed)) return collected.join('\n');
|
|
if (/^fi\s*$/.test(trimmed) && i > startIdx + 3) {
|
|
sawFi = true;
|
|
// Peek next line — if it's blank and the line after is not part of
|
|
// the new-fix `done`, treat fi as terminator (old block).
|
|
const next = (lines[i + 1] || '').trim();
|
|
const next2 = (lines[i + 2] || '').trim();
|
|
const newFixContinues = /^done\s*<\s*<\(find/.test(next) || /^done\s*<\s*<\(find/.test(next2);
|
|
if (!newFixContinues) {
|
|
return collected.join('\n');
|
|
}
|
|
}
|
|
}
|
|
assert.fail(`No terminator found in rescue block of ${filePath}`);
|
|
return collected.join('\n');
|
|
}
|
|
|
|
function sh(cwd, cmd) {
|
|
const r = spawnSync('bash', ['-c', cmd], { cwd, encoding: 'utf-8' });
|
|
if (r.status !== 0) {
|
|
throw new Error(`Command failed (${r.status}): ${cmd}\nstdout: ${r.stdout}\nstderr: ${r.stderr}`);
|
|
}
|
|
return r.stdout;
|
|
}
|
|
|
|
/**
|
|
* Build a temp repo that mirrors the bug repro from issue #2838 and run
|
|
* the rescue block against it. Returns { tmp, wt, summaryFinalPath }.
|
|
*/
|
|
function runRescueScenario(rescueBlock) {
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2838-'));
|
|
const wt = path.join(tmp, 'wt');
|
|
|
|
sh(tmp, 'git init -q -b main');
|
|
sh(tmp, 'git config user.email test@example.com && git config user.name test');
|
|
fs.writeFileSync(path.join(tmp, '.gitignore'), '.planning/\n');
|
|
fs.writeFileSync(path.join(tmp, 'README.md'), 'init\n');
|
|
sh(tmp, 'git add .gitignore README.md && git commit -q -m init');
|
|
|
|
// Create worktree on a feature branch
|
|
sh(tmp, `git worktree add -q -b sim-executor "${wt}"`);
|
|
|
|
// Simulate the executor: untracked SUMMARY.md under gitignored .planning/
|
|
const summaryDir = path.join(wt, '.planning', 'quick', '260429-repro');
|
|
fs.mkdirSync(summaryDir, { recursive: true });
|
|
const summarySrc = path.join(summaryDir, '260429-repro-SUMMARY.md');
|
|
fs.writeFileSync(summarySrc, 'status: complete\nrescued: yes\n');
|
|
|
|
// Confirm precondition: --exclude-standard misses the file (this is the bug)
|
|
const ls = sh(tmp, `git -C "${wt}" ls-files --modified --others --exclude-standard -- "*SUMMARY.md" || true`);
|
|
assert.strictEqual(ls.trim(), '', 'Precondition: --exclude-standard must hide gitignored SUMMARY');
|
|
|
|
// Run the rescue block. It expects WT, WT_BRANCH to be set, and to be run
|
|
// from the main repo root.
|
|
const script = `
|
|
set -u
|
|
WT="${wt}"
|
|
WT_BRANCH="sim-executor"
|
|
cd "${tmp}"
|
|
${rescueBlock}
|
|
`;
|
|
const r = spawnSync('bash', ['-c', script], { cwd: tmp, encoding: 'utf-8' });
|
|
// Don't fail on non-zero — original block has || true everywhere; we
|
|
// judge by outcome.
|
|
|
|
// Now do the worktree removal that would have lost the file
|
|
sh(tmp, `git worktree remove "${wt}" --force`);
|
|
|
|
const summaryFinalPath = path.join(tmp, '.planning', 'quick', '260429-repro', '260429-repro-SUMMARY.md');
|
|
return { tmp, summaryFinalPath, rescueOut: r.stdout + r.stderr };
|
|
}
|
|
|
|
function cleanup(tmp) {
|
|
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) {}
|
|
}
|
|
|
|
describe('bug-2838: SUMMARY rescue handles gitignored .planning/', () => {
|
|
test('execute-phase.md rescue block recovers SUMMARY when .planning/ is gitignored', () => {
|
|
const block = extractRescueBlock(EXECUTE_PHASE_PATH);
|
|
const { tmp, summaryFinalPath, rescueOut } = runRescueScenario(block);
|
|
try {
|
|
assert.ok(
|
|
fs.existsSync(summaryFinalPath),
|
|
`SUMMARY was lost — rescue did not surface the file into main repo.\nRescue output:\n${rescueOut}`
|
|
);
|
|
const content = fs.readFileSync(summaryFinalPath, 'utf-8');
|
|
const footer = parseRescueFooter(content);
|
|
assert.equal(footer.rescued, 'yes',
|
|
`expected typed footer.rescued === 'yes', got ${JSON.stringify(footer)}`);
|
|
} finally {
|
|
cleanup(tmp);
|
|
}
|
|
});
|
|
|
|
test('quick.md rescue block recovers SUMMARY when .planning/ is gitignored', () => {
|
|
const block = extractRescueBlock(QUICK_PATH);
|
|
const { tmp, summaryFinalPath, rescueOut } = runRescueScenario(block);
|
|
try {
|
|
assert.ok(
|
|
fs.existsSync(summaryFinalPath),
|
|
`SUMMARY was lost — rescue did not surface the file into main repo.\nRescue output:\n${rescueOut}`
|
|
);
|
|
const content = fs.readFileSync(summaryFinalPath, 'utf-8');
|
|
const footer = parseRescueFooter(content);
|
|
assert.equal(footer.rescued, 'yes',
|
|
`expected typed footer.rescued === 'yes', got ${JSON.stringify(footer)}`);
|
|
} finally {
|
|
cleanup(tmp);
|
|
}
|
|
});
|
|
|
|
test('rescue is idempotent when SUMMARY already present in main repo', () => {
|
|
const block = extractRescueBlock(EXECUTE_PHASE_PATH);
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2838-idem-'));
|
|
const wt = path.join(tmp, 'wt');
|
|
try {
|
|
sh(tmp, 'git init -q -b main');
|
|
sh(tmp, 'git config user.email test@example.com && git config user.name test');
|
|
fs.writeFileSync(path.join(tmp, '.gitignore'), '.planning/\n');
|
|
fs.writeFileSync(path.join(tmp, 'README.md'), 'init\n');
|
|
sh(tmp, 'git add .gitignore README.md && git commit -q -m init');
|
|
sh(tmp, `git worktree add -q -b sim-executor "${wt}"`);
|
|
|
|
const dir = path.join(wt, '.planning', 'quick', 'x');
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
const body = 'identical\n';
|
|
fs.writeFileSync(path.join(dir, 'x-SUMMARY.md'), body);
|
|
|
|
// Pre-place the same content in main repo
|
|
const mainDir = path.join(tmp, '.planning', 'quick', 'x');
|
|
fs.mkdirSync(mainDir, { recursive: true });
|
|
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
|
|
WT="${wt}"
|
|
WT_BRANCH="sim-executor"
|
|
cd "${tmp}"
|
|
${block}
|
|
`;
|
|
const r = spawnSync('bash', ['-c', script], { cwd: tmp, encoding: 'utf-8' });
|
|
assert.strictEqual(
|
|
r.status,
|
|
0,
|
|
`Rescue block failed unexpectedly.\nstdout: ${r.stdout}\nstderr: ${r.stderr}`
|
|
);
|
|
// 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);
|
|
}
|
|
});
|
|
});
|