Files
msd-core/tests/state-rebuild-cli.test.cjs
Tom Boucher 69e7afd0c7 chore(#3212): bounded quantifiers over document content — prohibition with teeth — Phase 4 (#3441)
* feat(#3415): ship local/no-unbounded-quantifier, burn down ReDoS class

Phase 4 of epic #3212 (ADR-3212 §5/§7, the final phase). New rule flags
an unbounded */+/{n,} quantifier over a broad character class
([\s\S], dotAll ., or a 1-2-unit negated class like [^\n]/[^)\n] — the
exact #2128-fixed shape) applied to a regex whose match target is
data-flow-traced to readFileSync content.

eslint-rules/lib/readfilesync-trace.cjs extracts the data-flow tracer
shared with no-crlf-fragile-split (Phase 2) rather than a second copy
— no-crlf-fragile-split refactored onto it with zero behavior change,
parity-tested.

Real triage, not 798 mechanical edits: the ADR's census (2026-08-08)
screened every unbounded quantifier in the tree unscoped. Correctly
scoped to readFileSync-derived content (matching Phase 2's own G2/G3
scoping), the rule found 162 real hits across two detection waves — the
second wave (93) surfaced only after a genuine off-by-one bug in this
rule's own first draft was caught while writing its RuleTester tests
and fixed (the bug silently missed every directly-quantified [\s\S]*
with no gap before the quantifier — exactly the class this rule exists
to catch). 3 hits landed in production src/ (commands.cts, milestone.cts,
roadmap.cts) and were each empirically timed against adversarial input
(matching #2128's own measured-not-assumed precedent) — all confirmed
linear-time/benign, left unbounded with a measured-evidence comment
rather than mechanically bounded. The remaining 159 are test-file
fixture parsing (test-author-controlled, fixed-size content, not
adversarial input) — each suppressed with a specific, non-generic
reason. Zero functional behavior changed anywhere in this diff.

tests/no-pending-3212-markers.test.cjs locks the epic's own closing
invariant (ADR §7: "assert zero pending #3212 markers remain") — ground
truth confirmed trivially true today (no phase left any such marker
behind), now regression-locked going forward.

Design: .gsd/phase/chore-3415-prohibition-with-teeth/40-design.md
Test matrix: .gsd/phase/chore-3415-prohibition-with-teeth/50-test-matrix.md

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#3415): correct rule category mislabel, add CI test-scope entry

An orthogonal Standards-axis review found eslint-rules/no-unbounded-quantifier.cjs
mistakenly carried meta.docs.category: 'Portability', copied from a sibling
rule without realizing what that implied: docs/contributing/cross-platform-
portability-rules.md governs an ADR-1703 rule family under a hard "zero
escape hatches" contract (tests/portability-rule-disable-ban.test.cjs's
PROTECTED_RULES bans eslint-disable for those rules entirely). This rule is
not part of that family — it's ADR-3212 (ReDoS/CWE-1333), a different epic —
and its eslint-disable-next-line suppressions (159 of them, added earlier
this same phase after empirical benign-verification) are an intentional,
correct design, not a bypass. Corrected to category: 'Best Practices',
matching the actual precedent (no-adhoc-regex-escape.cjs, Phase 1 of the
same epic, which is also correctly outside PROTECTED_RULES), and the rule's
own docstring now states this explicitly so a future reader doesn't have to
re-derive it.

Also registers a new scripts/ci-test-scope.cjs bucket so editing this rule
or the shared eslint-rules/lib/readfilesync-trace.cjs helper re-runs their
own test suites under targeted CI selection — was previously unregistered
and invisible to that fast-path (this PR's own gsd-test checkpoint runs the
full suite regardless, so this only affects future narrowly-scoped PRs).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#3415): bound no-unbounded-quantifier's own scanner (CWE-1333, ironic)

Security review found the rule meant to catch algorithmic-complexity bugs
had one of its own: hasUnboundedBroadQuantifier's negated-class inner
scan walked from each `[^` occurrence to the next `]` (or EOF) with no
bound, while the outer loop only ever advanced by one character — O(n²)
total work on a pattern with many unclosed `[^` runs. Runs unconditionally
inside checkPattern on any `new RegExp('literal string')` argument in any
linted file, before the (cheap) readFileSync data-flow gate — so a single
crafted string literal, no valid regex syntax required, could make
`npm run lint` / CI hang.

Empirically confirmed both the bug and the fix: pre-fix, n=4000/8000/
16000/32000 chars took 30.8/115.6/463.8/1874.3ms (~4x work per 2x n,
quadratic); extrapolated, the 300000-char repro from the finding would
run ~165s. Post-fix (bail the inner scan once units exceeds the rule's
own 1-2-unit scope, rather than continuing to hunt for a closing `]`),
the same 300000-char input runs in 8.7ms via the real rule module,
independently reconfirmed at 18ms via a fresh Linter.verify() call.

New regression row in tests/no-unbounded-quantifier.rule.test.cjs
asserts the RuleTester run on a 50000-char adversarial pattern
completes and returns a defined result — no wall-clock assertion
(CLAUDE.md Clock Seams / local/no-elapsed-assertion).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#3415): triage 3 new sites, re-raise ceiling after upstream batch

next merged 12 more PRs during this PR's review. Two consequences:

- tests/edit-phase.test.cjs (fix #3262, unrelated) added 3 new
  content.match(/<tag>([\s\S]*?)<\/tag>/) reads of this repo's own
  workflow .md content — the same Class A pattern as the ~159 sites
  already triaged elsewhere in this PR. Suppressed with the same
  established reason.
- lint-allow-test-rule-refs' ratchet ceiling needed re-raising again
  (301 -> 303) for the same reason as the two prior bumps: organic
  growth from unrelated, already-reviewed PRs landing concurrently,
  not a defect in this branch's own diff.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 10:02:28 -04:00

426 lines
18 KiB
JavaScript

'use strict';
// Phase 2 integration tests for the `state rebuild` CLI subcommand (#1826).
//
// These tests exercise the CLI dispatch path (routeStateCommand → cmdStateRebuild),
// the --dry-run flag (no write), and the --verbose flag (stderr emit). The
// underlying rebuildCore logic is covered by tests/state-rebuild.test.cjs (Phase 1).
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { runNode } = require('./helpers/process-seam.cjs');
const { throwIfFailed } = require('./helpers/git-fixture.cjs');
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const {
createTempProject,
cleanup,
runGsdTools,
} = require('./helpers.cjs');
const { withFaultyFs } = require('./helpers/faulty-deps.cjs');
const stateMod = require('../gsd-core/bin/lib/state.cjs');
const { stateExtractField } = require('../gsd-core/bin/lib/state-document.cjs');
const { parseMarkdownTable } = require('../gsd-core/bin/lib/markdown-table.cjs');
const { collectSection } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs');
const TOOLS_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
/**
* Write a STATE.md with one drift signature (stale Current Phase in body vs
* frontmatter) into a freshly-created temp project. Returns the temp dir.
*/
function projectWithDriftedState() {
const cwd = createTempProject('state-rebuild-cli');
const planningPath = path.join(cwd, '.planning');
// Drop two phase dirs so phaseInventoryProvider has something to scan.
fs.mkdirSync(path.join(planningPath, 'phases', '01-phase-one'), { recursive: true });
fs.writeFileSync(path.join(planningPath, 'phases', '01-phase-one', '01-PLAN.md'), '# Plan');
fs.mkdirSync(path.join(planningPath, 'phases', '02-phase-two'), { recursive: true });
fs.writeFileSync(path.join(planningPath, 'phases', '02-phase-two', '01-PLAN.md'), '# Plan');
// STATE.md with a drift: body says Current Phase 1, frontmatter says 2.
const stateContent = [
'---',
'gsd_state_version: \'1.0\'',
'status: executing',
'milestone: 1.0.0',
'milestone_name: Test',
'current_phase: 2',
'current_phase_name: Phase Two',
'current_plan: 1',
'progress:',
' total_phases: 2',
' completed_phases: 1',
' total_plans: 2',
' completed_plans: 1',
' percent: 50',
'---',
'',
'# Project State',
'',
'## Project Reference',
'',
'**Core value:** A test project',
'**Current focus:** Phase Two',
'',
'## Current Position',
'',
'**Current Phase:** 1',
'**Current Phase Name:** Phase One',
'**Current Plan:** 1',
'**Total Plans in Phase:** 1',
'**Status:** executing',
'**Last Activity:** 2026-06-29',
'**Last Activity Description:** mid-flight',
'',
'Phase: 2 of 2 (Phase Two)',
'Plan: 1 of 1',
'Status: Executing Phase 2',
'Last activity: 2026-06-29 — mid-flight',
'',
'**Progress:** [█████░░░░░] 50%',
'',
'## Performance Metrics',
'',
'**By Phase:**',
'',
'| Phase | Plans | Total | Avg/Plan |',
'|-------|-------|-------|----------|',
'| 99 | 1 | - | - |',
'',
'## Accumulated Context',
'',
'### Decisions',
'',
'None yet.',
'',
'## Session Continuity',
'',
'Last session: 2026-06-29 12:00',
'Stopped at: mid-flight',
'Resume file: None',
'',
].join('\n');
fs.writeFileSync(path.join(planningPath, 'STATE.md'), stateContent);
return cwd;
}
/** Read the live STATE.md from a project (strips the audit log so assertions
* check the canonical body, not the appended ## Rebuild Log entries). */
function readLiveState(cwd) {
const statePath = path.join(cwd, '.planning', 'STATE.md');
const content = fs.readFileSync(statePath, 'utf8');
// Strip ## Rebuild Log and everything after for shape assertions.
// eslint-disable-next-line local/no-unbounded-quantifier -- parses STATE.md this test wrote via a fixture, fixed-size test-controlled content
return content.replace(/^## Rebuild Log[\s\S]*$/m, '');
}
/**
* A project whose ONLY drift is the `**By Phase:**` table (an orphan row for
* phase 99, which isn't on disk). Every other body/frontmatter field is
* already canonical, so `rebuildCore`'s other reconciliation steps are all
* no-ops — the ONLY thing that can add a `## Rebuild Log` entry / flip
* `mutated` is whether the phase-inventory disk scan actually reconciles the
* table. This isolates the phase-inventory-scan outcome for #3057 B1.
*/
function projectWithOnlyPhaseTableDrift() {
const cwd = createTempProject('state-rebuild-cli-scan');
const planningPath = path.join(cwd, '.planning');
fs.mkdirSync(path.join(planningPath, 'phases', '01-phase-one'), { recursive: true });
fs.writeFileSync(path.join(planningPath, 'phases', '01-phase-one', '01-PLAN.md'), '# Plan');
const stateContent = [
'---',
'gsd_state_version: \'1.0\'',
'status: executing',
'milestone: 1.0.0',
'milestone_name: Test',
'current_phase: 1',
'current_phase_name: Phase One',
'current_plan: 1',
'progress:',
' total_phases: 1',
' completed_phases: 0',
' total_plans: 1',
' completed_plans: 0',
' percent: 0',
'---',
'',
'# Project State',
'',
'## Project Reference',
'',
'**Core value:** A test project',
'**Current focus:** Phase One',
'',
'## Current Position',
'',
'**Current Phase:** 1',
'**Current Phase Name:** Phase One',
'**Current Plan:** 1',
'**Total Plans in Phase:** 1',
'**Status:** executing',
'**Last Activity:** 2026-06-29',
'**Last Activity Description:** mid-flight',
'',
'Phase: 1 of 1 (Phase One)',
'Plan: 1 of 1',
'Status: Executing Phase 1',
'Last activity: 2026-06-29 — mid-flight',
'',
'**Progress:** [░░░░░░░░░░] 0%',
'',
'## Performance Metrics',
'',
'**By Phase:**',
'',
'| Phase | Plans | Total | Avg/Plan |',
'|-------|-------|-------|----------|',
'| 1 | 1 | - | - |',
'| 99 | 1 | - | - |',
'',
'## Accumulated Context',
'',
'### Decisions',
'',
'None yet.',
'',
'## Session Continuity',
'',
'Last session: 2026-06-29 12:00',
'Stopped at: mid-flight',
'Resume file: None',
'',
].join('\n');
fs.writeFileSync(path.join(planningPath, 'STATE.md'), stateContent);
return cwd;
}
/**
* Typed presence check for the `## Rebuild Log` audit-log section, shared by
* both call sites that need it (CONTRIBUTING: no raw-text `.includes()` on
* produced STATE.md text). Built on the existing `collectSection` seam
* (markdown-sectionizer.cjs) — a `Section | null`, not string matching.
*/
function hasRebuildLogSection(content) {
return collectSection(content, (h) => h.text.trim() === 'Rebuild Log') !== null;
}
/**
* Run `fn` while capturing every fd-1 write `cmdStateRebuild`'s `output()`
* performs (it writes via a raw `fs.writeSync(1, ...)`, never
* `console.log`/`process.stdout.write` — same seam `tests/io.test.cjs`
* exercises for bug #1008). Standalone helper with no test context, so the
* try/finally restore is CONTRIBUTING-compliant (same shape as
* `withFaultyFs`).
*/
function captureStdout(fn) {
const chunks = [];
const original = fs.writeSync;
fs.writeSync = (fd, data, offset, length) => {
if (fd !== 1) return original(fd, data, offset, length);
const chunk = Buffer.isBuffer(data)
? data.subarray(offset ?? 0, length === undefined ? data.length : (offset ?? 0) + length).toString('utf8')
: String(data);
chunks.push(chunk);
return Buffer.byteLength(chunk, 'utf8');
};
try {
fn();
} finally {
fs.writeSync = original;
}
return chunks.join('');
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('ADR-1817 Phase 2: `state rebuild` CLI subcommand dispatch (criterion #5 + end-to-end)', () => {
test('`state rebuild` with no flags reconciles drifted body fields and writes', (t) => {
const cwd = projectWithDriftedState();
t.after(() => cleanup(cwd));
const result = runGsdTools('state rebuild', cwd);
assert.ok(result.success, `state rebuild should succeed; stderr: ${result.stderr || result.error || ''}`);
// Body fields reconciled with frontmatter.
const live = readLiveState(cwd);
assert.strictEqual(stateExtractField(live, 'Current Phase'), '2',
'body Current Phase must be reconciled to frontmatter value 2');
assert.strictEqual(stateExtractField(live, 'Current Phase Name'), 'Phase Two',
'body Current Phase Name must be reconciled to frontmatter value');
// Orphan table row dropped (phase 99 is not on disk).
const byPhaseTable = parseMarkdownTable(live);
assert.ok(byPhaseTable.ok, `By Phase table must parse; reason: ${byPhaseTable.ok ? '' : byPhaseTable.reason}`);
const phaseIds = byPhaseTable.value.rows.map((r) => r.Phase);
assert.ok(!phaseIds.includes('99'),
`orphan row for phase 99 must be dropped (phaseInventoryProvider wired to disk scan); rows: ${JSON.stringify(phaseIds)}`);
// Audit log appended.
const fullState = fs.readFileSync(path.join(cwd, '.planning', 'STATE.md'), 'utf8');
assert.ok(hasRebuildLogSection(fullState),
'audit log section must be appended');
});
test('`state rebuild --dry-run` computes the diff but writes nothing', (t) => {
const cwd = projectWithDriftedState();
t.after(() => cleanup(cwd));
const before = fs.readFileSync(path.join(cwd, '.planning', 'STATE.md'), 'utf8');
const result = runGsdTools('state rebuild --dry-run', cwd);
assert.ok(result.success, `state rebuild --dry-run should succeed; error: ${result.error || ''}`);
const after = fs.readFileSync(path.join(cwd, '.planning', 'STATE.md'), 'utf8');
assert.strictEqual(after, before,
'--dry-run must NOT modify STATE.md on disk');
// The structured output should signal mutations would occur (the fixture
// has drift, so mutated=true in dry-run preview). `state rebuild --dry-run`
// emits pure JSON to stdout (src/state.cts:3221 `cmdStateRebuild`'s dry-run
// branch: `emit({ ..., mutated, ... })`).
const parsed = JSON.parse(result.output);
assert.strictEqual(parsed.mutated, true,
`dry-run output should report mutated:true; output: ${result.output}`);
});
test('`state rebuild --verbose` emits audit-log entries to stderr', (t) => {
const cwd = projectWithDriftedState();
t.after(() => cleanup(cwd));
// runGsdTools returns stdout only on success; --verbose writes to stderr,
// so invoke gsd-tools directly to capture both streams separately.
const r = runNode(
[TOOLS_PATH, 'state', 'rebuild', '--verbose'],
{ cwd, timeoutMs: PROBE_TIMEOUT_MS },
);
throwIfFailed(r, 'gsd-tools state rebuild --verbose');
const stdout = r.stdout;
// The seam's runNode captures stderr separately (unlike execFileSync's
// default inherited stderr) but this test does not need it — see below.
// Assert via the canonical record written to STATE.md: the audit log
// section is always appended (mutated fixture), and --verbose merely
// tees the same entries to stderr. The functional guarantee (audit log
// written) is what matters; the stderr tee is a convenience.
const after = fs.readFileSync(path.join(cwd, '.planning', 'STATE.md'), 'utf8');
assert.ok(hasRebuildLogSection(after),
'--verbose must still produce the audit log section in STATE.md');
// The real (non-dry-run) path emits `{ rebuilt: capturedMutated, ... }`
// to stdout as pure JSON (src/state.cts:3254 `cmdStateRebuild`). The
// fixture has drift, so `rebuilt` must be true.
const parsedStdout = JSON.parse(stdout);
assert.strictEqual(parsedStdout.rebuilt, true,
`--verbose stdout must report rebuilt:true; got: ${stdout.slice(0, 200)}`);
});
test('`state rebuild` on a clean STATE.md is a no-op (idempotency, end-to-end)', (t) => {
const cwd = projectWithDriftedState();
t.after(() => cleanup(cwd));
// First run: reconcile the drift.
const first = runGsdTools('state rebuild', cwd);
assert.ok(first.success, 'first rebuild should succeed');
const afterFirst = fs.readFileSync(path.join(cwd, '.planning', 'STATE.md'), 'utf8');
// Second run: should detect no drift, write nothing beyond what's there.
const second = runGsdTools('state rebuild', cwd);
assert.ok(second.success, 'second rebuild should succeed');
const afterSecond = fs.readFileSync(path.join(cwd, '.planning', 'STATE.md'), 'utf8');
assert.strictEqual(afterSecond, afterFirst,
'second rebuild on the just-rebuilt file must be byte-identical (idempotency)');
});
test('`state rebuild` on a missing STATE.md emits a clean error, no stack trace', (t) => {
const cwd = createTempProject('state-rebuild-missing');
t.after(() => cleanup(cwd));
// No STATE.md written.
const result = runGsdTools('state rebuild', cwd);
// The command emits a typed JSON error result and exits 0 — it does not
// crash the process (src/state.cts:3136 `cmdStateRebuild`'s missing-file
// guard: `emit({ error: 'STATE.md not found' }, raw); return;`, no
// `process.exit`). `result.success` proves the clean exit (a crash would
// flip it to false and populate `result.error` with a raw stack trace
// instead); `JSON.parse` proves stdout is exactly the structured payload
// with nothing else — including no leaked stack-trace text — mixed in.
assert.ok(result.success,
`state rebuild on a missing STATE.md should exit cleanly (no crash); stderr: ${result.error || ''}`);
const parsed = JSON.parse(result.output);
assert.strictEqual(parsed.error, 'STATE.md not found',
`missing STATE.md should produce a typed error field; output: ${result.output}`);
});
});
// ---------------------------------------------------------------------------
// #3057 B1: the PRODUCTION `phaseInventoryProvider` closure (state.cts:3106),
// not just the pure `reconcileByPhaseTable` core, must distinguish a real
// disk-scan failure from a genuinely-empty/reconciled scan. In-process fault
// injection via `withFaultyFs` on the real `fs.readdirSync` the closure
// calls — never chmod, never the subprocess seam (both banned per
// tests/helpers/faulty-deps.cjs's module doc).
// ---------------------------------------------------------------------------
describe('#3057 B1: cmdStateRebuild (production adapter) surfaces a real phase-inventory scan failure', () => {
test('FAILURE path: readdirSync(.planning/phases) faulted → phase_inventory_scan_failed:true, table left untouched, mutated:false', (t) => {
const cwd = projectWithOnlyPhaseTableDrift();
t.after(() => cleanup(cwd));
const phasesDir = path.join(cwd, '.planning', 'phases');
const originalReaddirSync = fs.readdirSync;
const stdout = withFaultyFs({
readdirSync: (p, ...rest) => {
if (String(p) === phasesDir) {
throw Object.assign(new Error('EACCES: permission denied, scandir ' + phasesDir), { code: 'EACCES' });
}
return originalReaddirSync(p, ...rest);
},
}, () => captureStdout(() => {
stateMod.cmdStateRebuild(cwd, { dryRun: true }, false);
}));
const parsed = JSON.parse(stdout);
assert.strictEqual(parsed.phase_inventory_scan_failed, true,
`a faulted disk scan must report phase_inventory_scan_failed:true; got: ${stdout}`);
assert.strictEqual(parsed.mutated, false,
'nothing else drifted in this fixture, so mutated must stay false — the failure is carried ONLY by the dedicated field');
assert.strictEqual(parsed.phase_inventory_scan_reason,
'EACCES: permission denied, scandir ' + phasesDir,
`phase_inventory_scan_reason must carry the exact fault message from the injected readdirSync throw; got: ${JSON.stringify(parsed.phase_inventory_scan_reason)}`);
// The orphan row must survive untouched — a failed scan is not a
// trustworthy inventory to reconcile the table against.
const live = readLiveState(cwd);
const byPhaseTable = parseMarkdownTable(live);
assert.ok(byPhaseTable.ok, `By Phase table must parse; reason: ${byPhaseTable.ok ? '' : byPhaseTable.reason}`);
const phaseIds = byPhaseTable.value.rows.map((r) => r.Phase);
assert.ok(phaseIds.includes('99'),
`orphan row for phase 99 must be preserved when the scan failed; rows: ${JSON.stringify(phaseIds)}`);
});
test('BENIGN path: the same fixture with an unfaulted disk scan reconciles the table and reports no failure', (t) => {
const cwd = projectWithOnlyPhaseTableDrift();
t.after(() => cleanup(cwd));
const stdout = captureStdout(() => {
stateMod.cmdStateRebuild(cwd, { dryRun: true }, false);
});
const parsed = JSON.parse(stdout);
assert.strictEqual(parsed.phase_inventory_scan_failed, false,
`an unfaulted disk scan must report phase_inventory_scan_failed:false; got: ${stdout}`);
assert.strictEqual(parsed.mutated, true,
'the real disk scan finds phase 1 only, so the orphan row 99 is real drift the (dry-run) rebuild would fix');
assert.strictEqual(parsed.phase_inventory_scan_reason, undefined,
'a clean scan must leave phase_inventory_scan_reason absent, distinguishing it from a faulted scan');
});
});