* 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>
1330 lines
58 KiB
JavaScript
1330 lines
58 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Tests for verification-status module (issue #651).
|
|
*
|
|
* Covers:
|
|
* 1. status: passed → routing
|
|
* 2. status: gaps_found with phase token extraction
|
|
* 3. status: human_needed → routing
|
|
* 4. No *-VERIFICATION.md → 'missing'
|
|
* 5. Frontmatter status present but unknown value → 'unknown'
|
|
* 6. BROAD-GREP REGRESSION: body `status:` lines ignored, frontmatter wins
|
|
* 7. PARITY: VERIFIER_STATUSES covered by routing table; gsd-verifier.md emitted statuses covered
|
|
* 8. CRLF line endings in frontmatter
|
|
* 9. Body-only file (no frontmatter block) → missing
|
|
* 10. Nonexistent phase directory → missing
|
|
* 11. Multiple *-VERIFICATION.md files → first by sort
|
|
* 12. ship.md PHASE_VERIFICATION_INCOMPLETE sentinel (contract anchor for #651 consolidation)
|
|
*
|
|
* PORTABILITY: pure JS — no shell-outs, no bash fences.
|
|
* Cross-platform (passes on Windows). Ref: DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.
|
|
*/
|
|
|
|
const { describe, test, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const os = require('node:os');
|
|
|
|
const { cleanup } = require('./helpers.cjs');
|
|
const { runGit: seamRunGit, OUTCOME } = require('./helpers/process-seam.cjs');
|
|
const { gitOrThrow } = require('./helpers/git-fixture.cjs');
|
|
|
|
const {
|
|
VERIFIER_STATUSES,
|
|
VERIFICATION_ROUTING_TABLE,
|
|
defaultPhaseCleanCommitTimesMs,
|
|
readVerificationStatus,
|
|
} = require('../gsd-core/bin/lib/verification.cjs');
|
|
|
|
// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs.
|
|
const { GIT_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Create a temporary phase directory under os.tmpdir().
|
|
* Returns the absolute path; caller must clean up.
|
|
*/
|
|
function mkPhaseDir(suffix) {
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), `gsd-651-${suffix}-`));
|
|
}
|
|
|
|
/**
|
|
* Write a *-VERIFICATION.md file with the given frontmatter status and
|
|
* optional body content.
|
|
*
|
|
* @param {string} dir - Phase directory path
|
|
* @param {string} filename - e.g. '01-review-VERIFICATION.md'
|
|
* @param {string} status - Frontmatter status value
|
|
* @param {string} [body] - Content after the closing `---`
|
|
*/
|
|
function writeVerificationMd(dir, filename, status, body = '') {
|
|
const frontmatter = `---\nstatus: ${status}\n---\n`;
|
|
fs.writeFileSync(path.join(dir, filename), frontmatter + body);
|
|
}
|
|
|
|
function setMtime(filePath, iso) {
|
|
const time = new Date(iso);
|
|
fs.utimesSync(filePath, time, time);
|
|
}
|
|
|
|
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
|
|
describe('verification-status', () => {
|
|
|
|
// ── Case 1: passed ────────────────────────────────────────────────────────
|
|
test('status: passed → next_command is empty, status is passed', () => {
|
|
const dir = mkPhaseDir('passed');
|
|
try {
|
|
writeVerificationMd(dir, '01-foo-VERIFICATION.md', 'passed');
|
|
const result = readVerificationStatus(dir);
|
|
assert.equal(result.status, 'passed', 'status must be passed');
|
|
assert.equal(result.next_command, '', 'next_command must be empty for passed');
|
|
assert.ok(result.next_action.length > 0, 'next_action must be non-empty');
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
// ── Case 2: gaps_found with phase token extraction ────────────────────────
|
|
test('status: gaps_found in "03-foo" dir → next_command includes phase token 03', () => {
|
|
// Phase dir basename starts with "03" — extractPhaseToken('03-foo') → '03'
|
|
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-651-parent-'));
|
|
const phaseDir = path.join(baseDir, '03-foo');
|
|
fs.mkdirSync(phaseDir);
|
|
try {
|
|
writeVerificationMd(phaseDir, '03-foo-VERIFICATION.md', 'gaps_found');
|
|
const result = readVerificationStatus(phaseDir);
|
|
assert.equal(result.status, 'gaps_found', 'status must be gaps_found');
|
|
assert.ok(
|
|
result.next_command.includes('03'),
|
|
`next_command should include phase token '03'; got: ${result.next_command}`,
|
|
);
|
|
assert.ok(
|
|
result.next_command.includes('--gaps'),
|
|
`next_command should include --gaps; got: ${result.next_command}`,
|
|
);
|
|
assert.equal(result.next_command, '/gsd-plan-phase 03 --gaps');
|
|
} finally {
|
|
cleanup(baseDir);
|
|
}
|
|
});
|
|
|
|
// ── Case 3: human_needed ──────────────────────────────────────────────────
|
|
test('status: human_needed → status human_needed, next_command is empty', () => {
|
|
const dir = mkPhaseDir('human-needed');
|
|
try {
|
|
writeVerificationMd(dir, '01-hn-VERIFICATION.md', 'human_needed');
|
|
const result = readVerificationStatus(dir);
|
|
assert.equal(result.status, 'human_needed');
|
|
// #2617: human_needed now names the command the next_action describes.
|
|
// This fixture's dir is not phase-shaped, so no number is appended.
|
|
assert.equal(result.next_command, '/gsd-verify-work');
|
|
assert.ok(result.next_action.length > 0);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
// ── Case 4: no *-VERIFICATION.md → missing ────────────────────────────────
|
|
test('no *-VERIFICATION.md file → status missing, next_command execute-phase', () => {
|
|
const dir = mkPhaseDir('missing');
|
|
try {
|
|
// write a non-matching file to confirm it is ignored
|
|
fs.writeFileSync(path.join(dir, 'README.md'), '# phase');
|
|
const result = readVerificationStatus(dir);
|
|
assert.equal(result.status, 'missing');
|
|
assert.equal(result.next_command, '/gsd-execute-phase');
|
|
assert.ok(result.next_action.includes('verify step never completed'));
|
|
assert.ok(
|
|
result.next_action.includes('does not re-run plans that already have a SUMMARY.md'),
|
|
`next_action must reassure the user execute-phase will not redo work (#1762); got: ${result.next_action}`,
|
|
);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
// ── Case 5: unknown frontmatter status value ──────────────────────────────
|
|
test("frontmatter status 'bogus' → status unknown, next_command execute-phase", () => {
|
|
const dir = mkPhaseDir('unknown');
|
|
try {
|
|
writeVerificationMd(dir, '01-u-VERIFICATION.md', 'bogus');
|
|
const result = readVerificationStatus(dir);
|
|
assert.equal(result.status, 'unknown');
|
|
assert.equal(result.next_command, '/gsd-execute-phase');
|
|
assert.ok(
|
|
result.next_action.includes('bogus'),
|
|
`next_action should mention the raw value; got: ${result.next_action}`,
|
|
);
|
|
assert.ok(
|
|
result.next_action.includes('intentional non-standard marker'),
|
|
`next_action must acknowledge an unrecognized status may be an intentional marker (#1762); got: ${result.next_action}`,
|
|
);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
// ── Case 6: BROAD-GREP REGRESSION (critical) ──────────────────────────────
|
|
//
|
|
// Frontmatter: `status: passed`
|
|
// Body: a fenced code block containing `status: gaps_found` AND `status: human_needed`
|
|
// Result MUST be 'passed' — proving body lines are NOT matched.
|
|
// This is the exact failure mode that issue #586 / PR #650 hit.
|
|
//
|
|
test('BROAD-GREP REGRESSION: body status lines ignored, frontmatter status wins', () => {
|
|
const dir = mkPhaseDir('broad-grep');
|
|
try {
|
|
const bodyWithEmbeddedStatuses = [
|
|
'',
|
|
'## Section',
|
|
'',
|
|
'Some prose about the results.',
|
|
'',
|
|
'```yaml',
|
|
'status: gaps_found',
|
|
'gaps:',
|
|
' - fix the thing',
|
|
'```',
|
|
'',
|
|
'Another block:',
|
|
'',
|
|
'```',
|
|
'status: human_needed',
|
|
'```',
|
|
'',
|
|
'End of document.',
|
|
].join('\n');
|
|
|
|
writeVerificationMd(dir, '01-bg-VERIFICATION.md', 'passed', bodyWithEmbeddedStatuses);
|
|
|
|
const result = readVerificationStatus(dir);
|
|
assert.equal(
|
|
result.status,
|
|
'passed',
|
|
`Expected status 'passed' (frontmatter wins); got '${result.status}'. ` +
|
|
'Body status: lines must NOT be matched.',
|
|
);
|
|
assert.equal(result.next_command, '', 'next_command must be empty for passed');
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
// ── Case 7: PARITY ASSERTION ──────────────────────────────────────────────
|
|
//
|
|
// (a) Every value in VERIFIER_STATUSES has a corresponding key in VERIFICATION_ROUTING_TABLE.
|
|
// (b) Parse agents/gsd-verifier.md for emitted statuses via /→ \*\*status:\s*([a-z_]+)\*\*/g,
|
|
// collect the set, and assert every emitted status is a routing key.
|
|
//
|
|
test('PARITY: VERIFIER_STATUSES covered by routing table', () => {
|
|
for (const s of VERIFIER_STATUSES) {
|
|
assert.ok(
|
|
s in VERIFICATION_ROUTING_TABLE,
|
|
`VERIFIER_STATUS '${s}' has no entry in VERIFICATION_ROUTING_TABLE`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('PARITY: gsd-verifier.md emitted statuses all have routing table entries', () => {
|
|
const verifierPath = path.join(__dirname, '..', 'agents', 'gsd-verifier.md');
|
|
const content = fs.readFileSync(verifierPath, 'utf-8');
|
|
|
|
const emittedStatuses = new Set();
|
|
|
|
// Source (a): decision-tree arrow lines — `→ **status: <value>**`
|
|
// These are the per-branch emission points in Step 9 (the decision tree).
|
|
const reArrow = /→ \*\*status:\s*([a-z_]+)\*\*/g;
|
|
let m;
|
|
while ((m = reArrow.exec(content)) !== null) {
|
|
emittedStatuses.add(m[1]);
|
|
}
|
|
|
|
// Source (b): output-template line — `status: A | B | C` (pipe-delimited list
|
|
// of permitted values inside the frontmatter template block in the <output> section).
|
|
// Anchored to lines that start with `status:` and contain `|` to avoid false
|
|
// matches on prose sentences that happen to mention "status:".
|
|
const reTemplate = /^status:\s+([a-z_]+(?:\s*\|\s*[a-z_]+)+)\s*$/gm;
|
|
while ((m = reTemplate.exec(content)) !== null) {
|
|
for (const token of m[1].split('|')) {
|
|
const t = token.trim();
|
|
if (t) emittedStatuses.add(t);
|
|
}
|
|
}
|
|
|
|
assert.ok(
|
|
emittedStatuses.size > 0,
|
|
'No emitted statuses found in gsd-verifier.md — regex or file path may be wrong. ' +
|
|
'Checked: (a) → **status: X** arrow lines, (b) status: A | B | C template lines.',
|
|
);
|
|
|
|
for (const s of emittedStatuses) {
|
|
assert.ok(
|
|
s in VERIFICATION_ROUTING_TABLE,
|
|
`gsd-verifier.md emits status '${s}' but VERIFICATION_ROUTING_TABLE has no entry for it. ` +
|
|
'Add a route or remove/rename the status in gsd-verifier.md.',
|
|
);
|
|
}
|
|
});
|
|
|
|
// ── Edge cases ────────────────────────────────────────────────────────────
|
|
|
|
// CRLF line endings in frontmatter
|
|
test('CRLF line endings in frontmatter → correct status parsed', () => {
|
|
const dir = mkPhaseDir('crlf');
|
|
try {
|
|
// Construct a file with CRLF line endings throughout
|
|
const content = '---\r\nstatus: passed\r\nphase: 01-demo\r\n---\r\n\r\n# Body\r\n';
|
|
fs.writeFileSync(path.join(dir, '01-crlf-VERIFICATION.md'), content);
|
|
const result = readVerificationStatus(dir);
|
|
assert.equal(result.status, 'passed', 'CRLF frontmatter must parse to passed');
|
|
assert.equal(result.next_command, '');
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
// File with NO frontmatter block — body-only `status:` line must NOT be matched
|
|
test('body-only file with no frontmatter block (status: in body) → missing', () => {
|
|
const dir = mkPhaseDir('no-fm');
|
|
try {
|
|
// No opening `---` — this is a plain markdown file with a status: line in the body
|
|
const content = '# Phase Verification\n\nstatus: passed\n\nSome notes.\n';
|
|
fs.writeFileSync(path.join(dir, '01-nofm-VERIFICATION.md'), content);
|
|
const result = readVerificationStatus(dir);
|
|
assert.equal(
|
|
result.status,
|
|
'missing',
|
|
"A body-only status: line must NOT be read — result should be 'missing'",
|
|
);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
// Missing / nonexistent phase directory → missing
|
|
test('nonexistent phase directory → missing', () => {
|
|
const nonexistent = path.join(os.tmpdir(), 'gsd-651-nonexistent-' + Date.now());
|
|
const result = readVerificationStatus(nonexistent);
|
|
assert.equal(result.status, 'missing', 'unreadable/nonexistent dir must return missing');
|
|
assert.equal(result.next_command, '/gsd-execute-phase');
|
|
});
|
|
|
|
// Multiple *-VERIFICATION.md files → deterministic pick (first by sort)
|
|
test('multiple *-VERIFICATION.md files in dir → first by sort order wins', () => {
|
|
const dir = mkPhaseDir('multi');
|
|
try {
|
|
// Write two files: alphabetically "01-a" comes before "02-b"
|
|
// "01-a" has passed; "02-b" has gaps_found — first by sort must win
|
|
const fm = (status) => `---\nstatus: ${status}\n---\n`;
|
|
fs.writeFileSync(path.join(dir, '01-a-VERIFICATION.md'), fm('passed'));
|
|
fs.writeFileSync(path.join(dir, '02-b-VERIFICATION.md'), fm('gaps_found'));
|
|
const result = readVerificationStatus(dir);
|
|
assert.equal(
|
|
result.status,
|
|
'passed',
|
|
'When multiple *-VERIFICATION.md files exist, the first by lexicographic sort must be used',
|
|
);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('passed verification older than a summary returns stale', () => {
|
|
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-651-parent-'));
|
|
const dir = path.join(baseDir, '01-stale-passed');
|
|
fs.mkdirSync(dir);
|
|
try {
|
|
const verificationPath = path.join(dir, '01-VERIFICATION.md');
|
|
const summaryPath = path.join(dir, '01-01-SUMMARY.md');
|
|
writeVerificationMd(dir, '01-VERIFICATION.md', 'passed');
|
|
fs.writeFileSync(summaryPath, '# Summary');
|
|
setMtime(verificationPath, '2026-01-01T00:00:00.000Z');
|
|
setMtime(summaryPath, '2026-01-01T00:01:00.000Z');
|
|
|
|
// git times unavailable → mtime-fallback path (#2348). Injected so the
|
|
// test stays hermetic (no git spawn) regardless of tmpdir repo state.
|
|
const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs: () => new Map() });
|
|
assert.equal(result.status, 'stale');
|
|
assert.match(result.next_action, /stale/i);
|
|
assert.equal(result.next_command, '/gsd-verify-work 01');
|
|
} finally {
|
|
cleanup(baseDir);
|
|
}
|
|
});
|
|
|
|
test('gaps_found verification older than a summary still returns gaps_found (not stale)', () => {
|
|
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-651-parent-'));
|
|
const dir = path.join(baseDir, '01-stale-gaps');
|
|
fs.mkdirSync(dir);
|
|
try {
|
|
const verificationPath = path.join(dir, '01-VERIFICATION.md');
|
|
const summaryPath = path.join(dir, '01-01-SUMMARY.md');
|
|
writeVerificationMd(dir, '01-VERIFICATION.md', 'gaps_found');
|
|
fs.writeFileSync(summaryPath, '# Summary');
|
|
setMtime(verificationPath, '2026-01-01T00:00:00.000Z');
|
|
setMtime(summaryPath, '2026-01-01T00:01:00.000Z');
|
|
|
|
const result = readVerificationStatus(dir);
|
|
assert.equal(result.status, 'gaps_found');
|
|
assert.equal(result.next_command, '/gsd-plan-phase 01 --gaps');
|
|
} finally {
|
|
cleanup(baseDir);
|
|
}
|
|
});
|
|
|
|
test('human_needed verification older than nested plans/SUMMARY-NN.md returns stale', () => {
|
|
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-651-parent-'));
|
|
const dir = path.join(baseDir, '01-stale-human-nested');
|
|
fs.mkdirSync(dir);
|
|
try {
|
|
const plansDir = path.join(dir, 'plans');
|
|
fs.mkdirSync(plansDir);
|
|
const verificationPath = path.join(dir, '01-VERIFICATION.md');
|
|
const summaryPath = path.join(plansDir, 'SUMMARY-01-manual.md');
|
|
writeVerificationMd(dir, '01-VERIFICATION.md', 'human_needed');
|
|
fs.writeFileSync(summaryPath, '# Summary');
|
|
setMtime(verificationPath, '2026-01-01T00:00:00.000Z');
|
|
setMtime(summaryPath, '2026-01-01T00:01:00.000Z');
|
|
|
|
// git times unavailable → mtime-fallback path (#2348).
|
|
const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs: () => new Map() });
|
|
assert.equal(result.status, 'stale');
|
|
assert.equal(result.next_command, '/gsd-verify-work 01');
|
|
} finally {
|
|
cleanup(baseDir);
|
|
}
|
|
});
|
|
|
|
// ── #2348: staleness derived from git commit time, not filesystem mtime ────
|
|
//
|
|
// The verification staleness gate must survive a fresh `git clone` / `cp -R`
|
|
// and an unrelated `touch`. It compares git commit times (content-tied) and
|
|
// only falls back to mtime when a file has no commit time (uncommitted / no
|
|
// repo), always reading both sides of a comparison from the same clock.
|
|
|
|
// Injectable per-phase git-commit-time resolver: given the phase-relative file
|
|
// names, returns Map<file, epoch-ms>. A file whose basename is absent from
|
|
// `byBase` resolves to "no git time" (uncommitted / not in git) → mtime clock.
|
|
const phaseCleanTimes = (byBase) => (_phaseDir, files) => {
|
|
const m = new Map();
|
|
for (const file of files) {
|
|
const base = file.split(/[\\/]/).pop();
|
|
if (Object.prototype.hasOwnProperty.call(byBase, base)) m.set(file, byBase[base]);
|
|
}
|
|
return m;
|
|
};
|
|
|
|
// git availability for the real-subprocess integration test below.
|
|
const GIT_AVAILABLE = (() => {
|
|
// Soft probe — a missing/broken git binary must resolve to `false`, not
|
|
// throw, so seamRunGit is used directly rather than gitOrThrow.
|
|
const r = seamRunGit(['--version'], { timeoutMs: GIT_TIMEOUT_MS });
|
|
return r.outcome === OUTCOME.EXITED && r.exitCode === 0;
|
|
})();
|
|
|
|
test('committed passed verification is NOT stale from mtime skew alone when the summary was not committed later (#2348)', () => {
|
|
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2348-parent-'));
|
|
const dir = path.join(baseDir, '02-clone-skew');
|
|
fs.mkdirSync(dir);
|
|
try {
|
|
const verificationPath = path.join(dir, '02-VERIFICATION.md');
|
|
const summaryPath = path.join(dir, '02-02-SUMMARY.md');
|
|
writeVerificationMd(dir, '02-VERIFICATION.md', 'passed');
|
|
fs.writeFileSync(summaryPath, '# Summary');
|
|
// Filesystem mtimes reproduce the reported 49s checkout skew (summary newer).
|
|
setMtime(verificationPath, '2026-07-16T22:53:49.000Z');
|
|
setMtime(summaryPath, '2026-07-16T22:54:38.000Z');
|
|
// But in git both were committed together — the summary is not newer.
|
|
const phaseCleanCommitTimesMs = phaseCleanTimes({
|
|
'02-VERIFICATION.md': Date.parse('2026-07-16T22:50:00.000Z'),
|
|
'02-02-SUMMARY.md': Date.parse('2026-07-16T22:50:00.000Z'),
|
|
});
|
|
|
|
const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs });
|
|
assert.equal(
|
|
result.status,
|
|
'passed',
|
|
'mtime skew alone must not override a committed passing verification',
|
|
);
|
|
assert.equal(result.next_command, '');
|
|
} finally {
|
|
cleanup(baseDir);
|
|
}
|
|
});
|
|
|
|
test('committed verification IS stale when the summary was committed later, even if its mtime is older — git clock wins (#2348)', () => {
|
|
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2348-parent-'));
|
|
const dir = path.join(baseDir, '02-git-stale');
|
|
fs.mkdirSync(dir);
|
|
try {
|
|
const verificationPath = path.join(dir, '02-VERIFICATION.md');
|
|
const summaryPath = path.join(dir, '02-02-SUMMARY.md');
|
|
writeVerificationMd(dir, '02-VERIFICATION.md', 'passed');
|
|
fs.writeFileSync(summaryPath, '# Summary');
|
|
// mtimes point the OTHER way (verification newer) to prove git is authoritative.
|
|
setMtime(verificationPath, '2026-07-16T23:00:00.000Z');
|
|
setMtime(summaryPath, '2026-07-16T22:00:00.000Z');
|
|
const phaseCleanCommitTimesMs = phaseCleanTimes({
|
|
'02-VERIFICATION.md': Date.parse('2026-07-16T22:50:00.000Z'),
|
|
'02-02-SUMMARY.md': Date.parse('2026-07-16T22:55:00.000Z'), // committed later
|
|
});
|
|
|
|
const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs });
|
|
assert.equal(result.status, 'stale');
|
|
assert.equal(result.next_command, '/gsd-verify-work 02');
|
|
} finally {
|
|
cleanup(baseDir);
|
|
}
|
|
});
|
|
|
|
test('git-clock staleness boundary: summary committed at V-1 / V / V+1 relative to verification (#2348)', () => {
|
|
const V = Date.parse('2026-07-16T22:50:00.000Z');
|
|
for (const { deltaMs, expected } of [
|
|
{ deltaMs: -1, expected: 'passed' },
|
|
{ deltaMs: 0, expected: 'passed' },
|
|
{ deltaMs: 1, expected: 'stale' },
|
|
]) {
|
|
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2348-boundary-'));
|
|
const dir = path.join(baseDir, '03-boundary');
|
|
fs.mkdirSync(dir);
|
|
try {
|
|
const verificationPath = path.join(dir, '03-VERIFICATION.md');
|
|
const summaryPath = path.join(dir, '03-03-SUMMARY.md');
|
|
writeVerificationMd(dir, '03-VERIFICATION.md', 'passed');
|
|
fs.writeFileSync(summaryPath, '# Summary');
|
|
setMtime(verificationPath, '2026-07-16T22:50:00.000Z');
|
|
setMtime(summaryPath, '2026-07-16T22:50:00.000Z');
|
|
const phaseCleanCommitTimesMs = phaseCleanTimes({
|
|
'03-VERIFICATION.md': V,
|
|
'03-03-SUMMARY.md': V + deltaMs,
|
|
});
|
|
|
|
const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs });
|
|
assert.equal(
|
|
result.status,
|
|
expected,
|
|
`summary committed at V${deltaMs >= 0 ? '+' : ''}${deltaMs}ms should be ${expected}`,
|
|
);
|
|
} finally {
|
|
cleanup(baseDir);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('a committed-clean verification is stale when a summary is edited afterward (dirty) — the edit is not shadowed by the summary commit time (#2348 dirty regression)', () => {
|
|
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2348-dirty-'));
|
|
const dir = path.join(baseDir, '02-dirty-summary');
|
|
fs.mkdirSync(dir);
|
|
try {
|
|
const verificationPath = path.join(dir, '02-VERIFICATION.md');
|
|
const summaryPath = path.join(dir, '02-02-SUMMARY.md');
|
|
writeVerificationMd(dir, '02-VERIFICATION.md', 'passed');
|
|
fs.writeFileSync(summaryPath, '# Summary');
|
|
// Verification is committed & clean at 22:50. The summary is DIRTY (edited
|
|
// on disk after its commit) so it is absent from the clean-commit map and
|
|
// must be timed by its mtime — a later edit at 22:54.
|
|
setMtime(verificationPath, '2026-07-16T22:50:00.000Z'); // unused (clean → commit time)
|
|
setMtime(summaryPath, '2026-07-16T22:54:00.000Z');
|
|
const phaseCleanCommitTimesMs = phaseCleanTimes({
|
|
'02-VERIFICATION.md': Date.parse('2026-07-16T22:50:00.000Z'),
|
|
// '02-02-SUMMARY.md' intentionally omitted → treated as dirty → mtime.
|
|
});
|
|
|
|
const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs });
|
|
assert.equal(
|
|
result.status,
|
|
'stale',
|
|
'a dirty summary edited after the verification must stale it via mtime, not be shadowed by an equal/earlier commit time',
|
|
);
|
|
assert.equal(result.next_command, '/gsd-verify-work 02');
|
|
} finally {
|
|
cleanup(baseDir);
|
|
}
|
|
});
|
|
|
|
test('both files uncommitted (no clean-commit time) fall back to mtime ordering (#2348)', () => {
|
|
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2348-uncommitted-'));
|
|
const dir = path.join(baseDir, '02-uncommitted');
|
|
fs.mkdirSync(dir);
|
|
try {
|
|
const verificationPath = path.join(dir, '02-VERIFICATION.md');
|
|
const summaryPath = path.join(dir, '02-02-SUMMARY.md');
|
|
writeVerificationMd(dir, '02-VERIFICATION.md', 'passed');
|
|
fs.writeFileSync(summaryPath, '# Summary');
|
|
// Neither file is committed → empty clean map → pure mtime comparison.
|
|
setMtime(verificationPath, '2026-07-16T23:00:00.000Z');
|
|
setMtime(summaryPath, '2026-07-16T22:00:00.000Z'); // summary older → not stale
|
|
const phaseCleanCommitTimesMs = phaseCleanTimes({});
|
|
|
|
const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs });
|
|
assert.equal(result.status, 'passed', 'summary older on the mtime clock → not stale');
|
|
} finally {
|
|
cleanup(baseDir);
|
|
}
|
|
});
|
|
|
|
test('the git-commit-time resolver is invoked at most once per phase, regardless of summary count (#2348 no per-file fan-out)', () => {
|
|
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2348-fanout-'));
|
|
const dir = path.join(baseDir, '01-fanout');
|
|
fs.mkdirSync(dir);
|
|
try {
|
|
writeVerificationMd(dir, '01-VERIFICATION.md', 'passed');
|
|
for (const n of ['01', '02', '03']) {
|
|
fs.writeFileSync(path.join(dir, `01-${n}-SUMMARY.md`), '# Summary');
|
|
}
|
|
let calls = 0;
|
|
let filesSeen = 0;
|
|
const phaseCleanCommitTimesMs = (_phaseDir, files) => {
|
|
calls += 1;
|
|
filesSeen = files.length;
|
|
return new Map();
|
|
};
|
|
|
|
readVerificationStatus(dir, { phaseCleanCommitTimesMs });
|
|
assert.equal(calls, 1, 'exactly one git walk for the whole phase, not one per summary file');
|
|
assert.equal(filesSeen, 4, 'the single walk receives the verification file + all 3 summaries');
|
|
} finally {
|
|
cleanup(baseDir);
|
|
}
|
|
});
|
|
|
|
test('a phase with no summary files performs zero git walks and is never stale (#2348)', () => {
|
|
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2348-nosummary-'));
|
|
const dir = path.join(baseDir, '01-no-summary');
|
|
fs.mkdirSync(dir);
|
|
try {
|
|
writeVerificationMd(dir, '01-VERIFICATION.md', 'passed');
|
|
let calls = 0;
|
|
const phaseCleanCommitTimesMs = () => {
|
|
calls += 1;
|
|
return new Map();
|
|
};
|
|
|
|
const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs });
|
|
assert.equal(result.status, 'passed');
|
|
assert.equal(calls, 0, 'no summaries → nothing can be newer → skip the git subprocess entirely');
|
|
} finally {
|
|
cleanup(baseDir);
|
|
}
|
|
});
|
|
|
|
test(
|
|
'real git: a summary committed after the verification reads stale via the real git clock, even for a dash-named file (#2348 end-to-end + `--` argv guard)',
|
|
{ skip: GIT_AVAILABLE ? false : 'git binary not available' },
|
|
() => {
|
|
const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2348-realgit-'));
|
|
const runGit = (args, extraEnv) =>
|
|
gitOrThrow(args, {
|
|
cwd: repo,
|
|
timeoutMs: GIT_TIMEOUT_MS,
|
|
env: { ...process.env, GIT_TERMINAL_PROMPT: '0', ...(extraEnv || {}) },
|
|
});
|
|
const commitEnvAt = (iso) => ({ GIT_AUTHOR_DATE: iso + '+00:00', GIT_COMMITTER_DATE: iso + '+00:00' });
|
|
try {
|
|
runGit(['init', '-q']);
|
|
runGit(['config', 'user.email', 'test@example.com']);
|
|
runGit(['config', 'user.name', 'Test']);
|
|
runGit(['config', 'commit.gpgsign', 'false']);
|
|
|
|
const dir = path.join(repo, '.planning', 'phases', '01-real');
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
const verificationPath = path.join(dir, '01-VERIFICATION.md');
|
|
// A leading-dash filename exercises the `--` pathspec guard in the real
|
|
// `git log` argv: if `--` were dropped git would read it as a flag.
|
|
const summaryName = '-danger-SUMMARY.md';
|
|
const summaryPath = path.join(dir, summaryName);
|
|
|
|
fs.writeFileSync(verificationPath, '---\nstatus: passed\n---\n');
|
|
runGit(['add', '--', verificationPath]);
|
|
runGit(['commit', '-q', '-m', 'add verification'], commitEnvAt('2026-07-16T22:50:00'));
|
|
|
|
fs.writeFileSync(summaryPath, '# Summary');
|
|
runGit(['add', '--', summaryPath]);
|
|
runGit(['commit', '-q', '-m', 'add summary later'], commitEnvAt('2026-07-16T22:55:00'));
|
|
|
|
// Make mtimes claim the OPPOSITE order so only the git clock can stale it.
|
|
setMtime(summaryPath, '2000-01-01T00:00:00.000Z');
|
|
setMtime(verificationPath, '2030-01-01T00:00:00.000Z');
|
|
|
|
// No seam injected → the real defaultPhaseCleanCommitTimesMs / execGit path.
|
|
const result = readVerificationStatus(dir);
|
|
assert.equal(
|
|
result.status,
|
|
'stale',
|
|
'summary committed after the verification must read stale on the real git clock, and the dash-named file must resolve through the `--` pathspec guard',
|
|
);
|
|
assert.equal(result.next_command, '/gsd-verify-work 01');
|
|
} finally {
|
|
cleanup(repo);
|
|
}
|
|
},
|
|
);
|
|
|
|
test(
|
|
'real git: a committed summary edited on disk (dirty) reads stale via mtime, not shadowed by its commit time (#2348 dirty regression, end-to-end)',
|
|
{ skip: GIT_AVAILABLE ? false : 'git binary not available' },
|
|
() => {
|
|
const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2348-realgit-dirty-'));
|
|
const runGit = (args, extraEnv) =>
|
|
gitOrThrow(args, {
|
|
cwd: repo,
|
|
timeoutMs: GIT_TIMEOUT_MS,
|
|
env: { ...process.env, GIT_TERMINAL_PROMPT: '0', ...(extraEnv || {}) },
|
|
});
|
|
const commitEnvAt = (iso) => ({ GIT_AUTHOR_DATE: iso + '+00:00', GIT_COMMITTER_DATE: iso + '+00:00' });
|
|
try {
|
|
runGit(['init', '-q']);
|
|
runGit(['config', 'user.email', 'test@example.com']);
|
|
runGit(['config', 'user.name', 'Test']);
|
|
runGit(['config', 'commit.gpgsign', 'false']);
|
|
|
|
const dir = path.join(repo, '.planning', 'phases', '01-real');
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
const verificationPath = path.join(dir, '01-VERIFICATION.md');
|
|
const summaryPath = path.join(dir, '01-01-SUMMARY.md');
|
|
|
|
fs.writeFileSync(verificationPath, '---\nstatus: passed\n---\n');
|
|
fs.writeFileSync(summaryPath, '# Summary');
|
|
// Commit BOTH together — identical commit time, so commit time alone
|
|
// would read "not stale".
|
|
runGit(['add', '--', verificationPath, summaryPath]);
|
|
runGit(['commit', '-q', '-m', 'add phase'], commitEnvAt('2026-07-16T22:50:00'));
|
|
|
|
// Edit the summary again WITHOUT committing → working tree diverges from HEAD.
|
|
fs.writeFileSync(summaryPath, '# Summary edited');
|
|
setMtime(verificationPath, '2026-07-16T22:50:00.000Z'); // clean → commit time used
|
|
setMtime(summaryPath, '2026-07-16T22:54:00.000Z'); // dirty → this later mtime is used
|
|
|
|
const result = readVerificationStatus(dir);
|
|
assert.equal(
|
|
result.status,
|
|
'stale',
|
|
'a committed-then-edited (dirty) summary must read stale via mtime, not be shadowed by its now-stale commit time',
|
|
);
|
|
assert.equal(result.next_command, '/gsd-verify-work 01');
|
|
} finally {
|
|
cleanup(repo);
|
|
}
|
|
},
|
|
);
|
|
|
|
// ── #2348: default resolver two-call error handling (hermetic, injected execGit) ──
|
|
|
|
const okResult = (stdout) => ({ exitCode: 0, stdout, stderr: '', signal: null, error: null });
|
|
const errResult = () => ({
|
|
exitCode: 127,
|
|
stdout: '',
|
|
stderr: 'git: not found',
|
|
signal: null,
|
|
error: new Error('ENOENT'),
|
|
});
|
|
const nonzeroResult = () => ({ exitCode: 128, stdout: '', stderr: 'fatal', signal: null, error: null });
|
|
// Fake execGit dispatching on the git subcommand (args[0]).
|
|
const fakeExecGit = ({ log, diff }) => (args) => {
|
|
if (args[0] === 'log') return log;
|
|
if (args[0] === 'diff') return diff;
|
|
throw new Error(`unexpected git ${args.join(' ')}`);
|
|
};
|
|
// Reverse-chronological `git log --name-only` fixture: summary newer than verification.
|
|
const LOG_OUT = [
|
|
'2000',
|
|
'',
|
|
'.planning/phases/01-x/01-01-SUMMARY.md',
|
|
'',
|
|
'1000',
|
|
'',
|
|
'.planning/phases/01-x/01-VERIFICATION.md',
|
|
].join('\n');
|
|
const FILES = ['01-VERIFICATION.md', '01-01-SUMMARY.md'];
|
|
|
|
test('resolver: parses commit times and drops a file the dirty-check reports (#2348)', () => {
|
|
const map = defaultPhaseCleanCommitTimesMs(
|
|
'/repo/.planning/phases/01-x',
|
|
FILES,
|
|
fakeExecGit({ log: okResult(LOG_OUT), diff: okResult('.planning/phases/01-x/01-01-SUMMARY.md') }),
|
|
);
|
|
assert.equal(map.get('01-VERIFICATION.md'), 1000 * 1000, 'verification commit time (seconds→ms)');
|
|
assert.equal(map.has('01-01-SUMMARY.md'), false, 'dirty summary dropped → will use mtime');
|
|
});
|
|
|
|
test('resolver: clean tree (dirty-check reports nothing) keeps all commit times (#2348)', () => {
|
|
const map = defaultPhaseCleanCommitTimesMs(
|
|
'/repo/.planning/phases/01-x',
|
|
FILES,
|
|
fakeExecGit({ log: okResult(LOG_OUT), diff: okResult('') }),
|
|
);
|
|
assert.equal(map.get('01-VERIFICATION.md'), 1000 * 1000);
|
|
assert.equal(map.get('01-01-SUMMARY.md'), 2000 * 1000);
|
|
});
|
|
|
|
test('resolver: FAILS SAFE (empty map) when the dirty-check errors after git log succeeds (#2348)', () => {
|
|
const map = defaultPhaseCleanCommitTimesMs(
|
|
'/repo/.planning/phases/01-x',
|
|
FILES,
|
|
fakeExecGit({ log: okResult(LOG_OUT), diff: errResult() }),
|
|
);
|
|
assert.equal(
|
|
map.size,
|
|
0,
|
|
'an inconclusive dirty-check must discard commit times so every file falls back to mtime',
|
|
);
|
|
});
|
|
|
|
test('resolver: FAILS SAFE (empty map) when the dirty-check exits non-zero (#2348)', () => {
|
|
const map = defaultPhaseCleanCommitTimesMs(
|
|
'/repo/.planning/phases/01-x',
|
|
FILES,
|
|
fakeExecGit({ log: okResult(LOG_OUT), diff: nonzeroResult() }),
|
|
);
|
|
assert.equal(map.size, 0);
|
|
});
|
|
|
|
test('resolver: empty map (mtime fallback) when git log itself fails (#2348)', () => {
|
|
const map = defaultPhaseCleanCommitTimesMs(
|
|
'/repo/.planning/phases/01-x',
|
|
FILES,
|
|
// diff would throw if consulted — proves log-failure short-circuits before it.
|
|
fakeExecGit({ log: errResult(), diff: undefined }),
|
|
);
|
|
assert.equal(map.size, 0);
|
|
});
|
|
|
|
// ── Task 2 (B1): ship.md gate sentinel contract anchor ────────────────────
|
|
//
|
|
// The deleted tests/ship-586-verification-routing.test.cjs was the only
|
|
// thing asserting that ship.md emits the PHASE_VERIFICATION_INCOMPLETE block
|
|
// sentinel (its user-visible gate error key). This test re-anchors that contract.
|
|
//
|
|
test('ship.md still emits the PHASE_VERIFICATION_INCOMPLETE gate sentinel (contract anchor for #651 consolidation)', () => {
|
|
const shipMdPath = path.join(__dirname, '..', 'gsd-core', 'workflows', 'ship.md');
|
|
const content = fs.readFileSync(shipMdPath, 'utf-8');
|
|
assert.ok(
|
|
content.includes('PHASE_VERIFICATION_INCOMPLETE'),
|
|
'ship.md must contain the literal PHASE_VERIFICATION_INCOMPLETE gate sentinel. ' +
|
|
'If you renamed or removed it, update the verification routing and this contract test.',
|
|
);
|
|
});
|
|
|
|
});
|
|
|
|
// ─── #3057 B3: findStaleVerificationSummary — indeterminate vs not-stale ─────
|
|
//
|
|
// The pre-fix catch-all returned `null` on ANY fs / scanPhasePlans / clock
|
|
// failure — identical to a completed check that genuinely found nothing
|
|
// stale. `opts.fs` had never been exercised by any test. These two tests
|
|
// confirm (a) the `opts.fs` injection seam actually works, and (b) the two
|
|
// outcomes are now distinguishable via `staleCheckIndeterminate` on the
|
|
// `readVerificationStatus` result.
|
|
|
|
describe('#3057 B3: staleness check — indeterminate is distinguishable from not-stale', () => {
|
|
test('an fs failure inside the staleness check yields staleCheckIndeterminate:true, not a silent "not stale"', (t) => {
|
|
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3057-b3-fault-'));
|
|
t.after(() => cleanup(baseDir));
|
|
const dir = path.join(baseDir, '01-stale-check-fault');
|
|
fs.mkdirSync(dir);
|
|
|
|
const verificationPath = path.join(dir, '01-VERIFICATION.md');
|
|
const summaryPath = path.join(dir, '01-01-SUMMARY.md');
|
|
writeVerificationMd(dir, '01-VERIFICATION.md', 'passed');
|
|
fs.writeFileSync(summaryPath, '# Summary');
|
|
// The summary IS newer — if the check ran to completion it would find
|
|
// 'stale'. The point of this test is that it never gets to find out.
|
|
setMtime(verificationPath, '2026-01-01T00:00:00.000Z');
|
|
setMtime(summaryPath, '2026-01-01T00:01:00.000Z');
|
|
|
|
// Confirms opts.fs is actually threaded through: readdirSync/readFileSync
|
|
// delegate to the real fs (so "find the VERIFICATION.md" / "read its
|
|
// frontmatter" upstream of the staleness check still succeed normally),
|
|
// and ONLY statSync is faulted — driving findStaleVerificationSummary's
|
|
// catch branch specifically, via the injected seam, not a global monkeypatch.
|
|
const fsLike = {
|
|
readdirSync: (d) => fs.readdirSync(d),
|
|
readFileSync: (p, enc) => fs.readFileSync(p, enc),
|
|
statSync: () => { throw new Error('injected stat failure (#3057 B3)'); },
|
|
};
|
|
|
|
const result = readVerificationStatus(dir, {
|
|
fs: fsLike,
|
|
phaseCleanCommitTimesMs: () => new Map(),
|
|
});
|
|
|
|
// Pre-existing no-throw fail-open contract is UNCHANGED: routing still
|
|
// proceeds as if nothing were stale (status stays 'passed', not 'stale' —
|
|
// a genuinely-stale summary sits right there and would have tripped the
|
|
// 'stale' route had the check run to completion).
|
|
assert.equal(result.status, 'passed');
|
|
// But the cause is no longer silently identical to a completed "nothing
|
|
// is stale" check — this MUST be flagged as indeterminate.
|
|
assert.strictEqual(result.staleCheckIndeterminate, true);
|
|
});
|
|
|
|
test('a completed staleness check that finds nothing stale never reports indeterminate', (t) => {
|
|
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3057-b3-ok-'));
|
|
t.after(() => cleanup(baseDir));
|
|
const dir = path.join(baseDir, '01-stale-check-ok');
|
|
fs.mkdirSync(dir);
|
|
|
|
const verificationPath = path.join(dir, '01-VERIFICATION.md');
|
|
const summaryPath = path.join(dir, '01-01-SUMMARY.md');
|
|
writeVerificationMd(dir, '01-VERIFICATION.md', 'passed');
|
|
fs.writeFileSync(summaryPath, '# Summary');
|
|
// Verification NEWER than the summary → the check runs to completion
|
|
// (no fault injected) and genuinely finds nothing stale.
|
|
setMtime(summaryPath, '2026-01-01T00:00:00.000Z');
|
|
setMtime(verificationPath, '2026-01-01T00:01:00.000Z');
|
|
|
|
const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs: () => new Map() });
|
|
|
|
assert.equal(result.status, 'passed');
|
|
assert.strictEqual(
|
|
result.staleCheckIndeterminate,
|
|
undefined,
|
|
'a completed check that found nothing stale must not be flagged indeterminate',
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── #2617: next_command runtime projection ──────────────────────────────────
|
|
//
|
|
// Regression tests for #2617 — verification-status `next_command` bypassed the
|
|
// runtime command-surface projection.
|
|
//
|
|
// `src/verification.cts` stored and synthesized hard-coded `/gsd:…` strings with
|
|
// no runtime context, and `phase complete` relayed that raw field straight into
|
|
// its verification-blocked error. On a Codex project the suggested next step was
|
|
// `/gsd:execute-phase`, which Codex does not install — the surface there is
|
|
// `$gsd-execute-phase`. The colon form is doubly wrong: `runtime-slash.cts`
|
|
// documents that "the colon form is never emitted", so every runtime was getting
|
|
// a deprecated shape. (The 11 `/gsd-…` assertions above were `/gsd:…` before this
|
|
// fix — they are the failing-first record.)
|
|
//
|
|
// The fix keeps ONE routing seam and makes its emitted command runtime-aware:
|
|
// the table stores bare command names and every return path projects through
|
|
// `formatGsdSlash`, with callers passing `resolveRuntime(cwd)`.
|
|
//
|
|
// Coverage is the matrix the issue asked for — missing, unknown, gaps_found and
|
|
// stale, against Codex (`$gsd-…`) and a slash-hyphen runtime (`/gsd-…`) — plus
|
|
// the `phase complete` error path, not merely the router's return object.
|
|
|
|
/** Codex installs `$gsd-<cmd>`; every other shipped runtime installs `/gsd-<cmd>`. */
|
|
const RUNTIMES = [
|
|
{ id: 'codex', prefix: '$gsd-' },
|
|
{ id: 'cursor', prefix: '/gsd-' },
|
|
];
|
|
|
|
// NOTE: deliberately NOT file-scope beforeEach/afterEach. node:test applies
|
|
// module-scope hooks to EVERY test in the file, so hooks added here for the
|
|
// #2617 suites would also wrap the ~40 pre-existing tests above — making this
|
|
// block a single point of failure for suites it has nothing to do with. Each
|
|
// test allocates and releases its own phase dir instead.
|
|
let projBaseDir;
|
|
let projPhaseDir;
|
|
|
|
/**
|
|
* Install the #2617 temp-phase-dir lifecycle INSIDE the calling describe.
|
|
* node:test scopes hooks to their enclosing describe, so this keeps them off the
|
|
* ~40 pre-existing tests in this file.
|
|
*/
|
|
function useProjectionPhaseDir() {
|
|
beforeEach(() => {
|
|
projBaseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2617-'));
|
|
projPhaseDir = path.join(projBaseDir, '01-example');
|
|
fs.mkdirSync(projPhaseDir, { recursive: true });
|
|
});
|
|
afterEach(() => cleanup(projBaseDir));
|
|
}
|
|
|
|
const verificationPath = () => path.join(projPhaseDir, '01-VERIFICATION.md');
|
|
|
|
function writeStatus(status) {
|
|
fs.writeFileSync(verificationPath(), `---\nstatus: ${status}\n---\n\n# Verification\n`);
|
|
}
|
|
|
|
function removeVerification() {
|
|
try { fs.unlinkSync(verificationPath()); } catch { /* already absent */ }
|
|
}
|
|
|
|
/** Make the verification file older than a summary → the stale branch. */
|
|
function makeStale() {
|
|
const summaryPath = path.join(projPhaseDir, '01-01-SUMMARY.md');
|
|
fs.writeFileSync(summaryPath, '# Summary\n');
|
|
fs.utimesSync(verificationPath(), new Date('2026-01-01T00:00:00Z'), new Date('2026-01-01T00:00:00Z'));
|
|
fs.utimesSync(summaryPath, new Date('2026-01-01T00:01:00Z'), new Date('2026-01-01T00:01:00Z'));
|
|
}
|
|
|
|
// git times unavailable → mtime-fallback path (#2348). Injected so the staleness
|
|
// clock stays hermetic regardless of the tmpdir's repo state.
|
|
const NO_GIT = { phaseCleanCommitTimesMs: () => new Map() };
|
|
|
|
function read(runtime, extra = {}) {
|
|
return readVerificationStatus(projPhaseDir, { runtime, ...extra });
|
|
}
|
|
|
|
for (const { id, prefix } of RUNTIMES) {
|
|
describe(`#2617: next_command uses the ${id} command surface`, () => {
|
|
useProjectionPhaseDir();
|
|
|
|
test('missing verification', () => {
|
|
removeVerification();
|
|
assert.equal(read(id).next_command, `${prefix}execute-phase 01`);
|
|
});
|
|
|
|
test('unparseable/absent frontmatter status is also "missing"', () => {
|
|
fs.writeFileSync(verificationPath(), '# Verification\n\nNo frontmatter here.\n');
|
|
assert.equal(read(id).next_command, `${prefix}execute-phase 01`);
|
|
});
|
|
|
|
test('unknown status value', () => {
|
|
writeStatus('not-a-real-status');
|
|
const result = read(id);
|
|
assert.equal(result.status, 'unknown');
|
|
assert.equal(result.next_command, `${prefix}execute-phase 01`);
|
|
});
|
|
|
|
test('gaps_found carries the phase number and --gaps flag through the projection', () => {
|
|
writeStatus('gaps_found');
|
|
const result = read(id);
|
|
assert.equal(result.status, 'gaps_found');
|
|
assert.equal(result.next_command, `${prefix}plan-phase 01 --gaps`);
|
|
});
|
|
|
|
test('stale carries the phase number through the projection', () => {
|
|
writeStatus('passed');
|
|
makeStale();
|
|
const result = read(id, NO_GIT);
|
|
assert.equal(result.status, 'stale');
|
|
assert.equal(result.next_command, `${prefix}verify-work 01`);
|
|
});
|
|
|
|
test('passed has no next step and stays empty, not a bare prefix', () => {
|
|
// Boundary: projecting an empty command must not emit `$gsd-` / `/gsd-`.
|
|
writeStatus('passed');
|
|
assert.equal(read(id).next_command, '',
|
|
'passed has no next command and must project to the empty string');
|
|
});
|
|
|
|
test('human_needed names the verify-work command its next_action describes', () => {
|
|
// #2617 unification: the table used to return '' here while init.cts's
|
|
// parallel projector returned `verify-work <N>` for the same state — the
|
|
// two surfaces disagreed on whether a next command existed at all.
|
|
writeStatus('human_needed');
|
|
assert.equal(read(id).next_command, `${prefix}verify-work 01`);
|
|
});
|
|
});
|
|
}
|
|
|
|
describe('#2617: no verification output suggests the deprecated colon form', () => {
|
|
useProjectionPhaseDir();
|
|
|
|
test('across every state and runtime, and for the default runtime', () => {
|
|
const runtimeIds = [...RUNTIMES.map((r) => r.id), undefined];
|
|
let checked = 0;
|
|
|
|
for (const runtime of runtimeIds) {
|
|
const opts = runtime === undefined ? { ...NO_GIT } : { runtime, ...NO_GIT };
|
|
|
|
removeVerification();
|
|
const cases = [readVerificationStatus(projPhaseDir, opts)];
|
|
|
|
for (const status of ['not-a-real-status', 'gaps_found', 'passed', 'human_needed']) {
|
|
writeStatus(status);
|
|
cases.push(readVerificationStatus(projPhaseDir, opts));
|
|
}
|
|
writeStatus('passed');
|
|
makeStale();
|
|
cases.push(readVerificationStatus(projPhaseDir, opts));
|
|
|
|
for (const result of cases) {
|
|
assert.ok(
|
|
!result.next_command.includes('/gsd:'),
|
|
`deprecated colon form leaked for runtime=${String(runtime)}: ${result.next_command}`,
|
|
);
|
|
checked++;
|
|
}
|
|
}
|
|
|
|
// Non-vacuity: 3 runtimes x 6 states.
|
|
assert.equal(checked, 18, 'expected every runtime x state combination to be checked');
|
|
});
|
|
|
|
test('the default runtime yields the canonical hyphen form, not the colon form', () => {
|
|
removeVerification();
|
|
// No `runtime` option at all — the pre-fix default emitted `/gsd:execute-phase`.
|
|
assert.equal(readVerificationStatus(projPhaseDir).next_command, '/gsd-execute-phase 01');
|
|
});
|
|
});
|
|
|
|
describe('#2617: the phase-complete error path projects too', () => {
|
|
// The issue is explicit that fixing only the router is insufficient: the
|
|
// user-visible surface is `phase complete`, which relays next_command into its
|
|
// blocked-completion error. Driven through the real CLI so the assertion is on
|
|
// what a user actually sees.
|
|
const { runGsdTools, createTempGitProject } = require('./helpers.cjs');
|
|
|
|
for (const { id, prefix } of RUNTIMES) {
|
|
test(`phase complete on ${id} suggests ${prefix}execute-phase`, () => {
|
|
const projectDir = createTempGitProject();
|
|
try {
|
|
fs.writeFileSync(
|
|
path.join(projectDir, '.planning', 'config.json'),
|
|
JSON.stringify({ runtime: id }, null, 2),
|
|
);
|
|
const phase = path.join(projectDir, '.planning', 'phases', '01-example');
|
|
fs.mkdirSync(phase, { recursive: true });
|
|
// No *-VERIFICATION.md → the completion gate blocks with reason "missing".
|
|
|
|
const res = runGsdTools(['phase', 'complete', '01'], projectDir);
|
|
// The blocked-completion message goes to stderr, which runGsdTools
|
|
// surfaces as `error` (NOT `stderr`) on a clean non-zero exit. Reading
|
|
// the wrong field yields '' and makes every assertion below vacuous.
|
|
const text = `${res.output || ''}${res.error || ''}`;
|
|
|
|
assert.equal(res.success, false, 'completion must be blocked with no verification report');
|
|
assert.match(
|
|
text,
|
|
/verification is incomplete/i,
|
|
`expected the blocked-completion error, got: ${text}`,
|
|
);
|
|
// Unconditional — a conditional check here passes when the command is
|
|
// absent entirely, which is exactly how this path stayed untested.
|
|
assert.ok(
|
|
text.includes(`${prefix}execute-phase`),
|
|
`phase complete must suggest ${prefix}execute-phase on ${id}, got: ${text}`,
|
|
);
|
|
assert.ok(
|
|
!text.includes('/gsd:'),
|
|
`phase complete must not surface the deprecated colon form: ${text}`,
|
|
);
|
|
} finally {
|
|
cleanup(projectDir);
|
|
}
|
|
});
|
|
|
|
test(`phase complete on ${id} projects the gaps_found command too`, () => {
|
|
// Finding from review: the live-CLI check previously exercised only the
|
|
// `missing` state, so a regression in any other routed branch would show
|
|
// up in the router's return object but not in what a user actually reads.
|
|
const projectDir = createTempGitProject();
|
|
try {
|
|
fs.writeFileSync(
|
|
path.join(projectDir, '.planning', 'config.json'),
|
|
JSON.stringify({ runtime: id }, null, 2),
|
|
);
|
|
const phase = path.join(projectDir, '.planning', 'phases', '01-example');
|
|
fs.mkdirSync(phase, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(phase, '01-VERIFICATION.md'),
|
|
'---\nstatus: gaps_found\n---\n\n# Verification\n',
|
|
);
|
|
|
|
const res = runGsdTools(['phase', 'complete', '01'], projectDir);
|
|
const text = `${res.output || ''}${res.error || ''}`;
|
|
|
|
assert.equal(res.success, false, 'gaps_found must block completion');
|
|
assert.ok(
|
|
text.includes(`${prefix}plan-phase 01 --gaps`),
|
|
`phase complete must suggest ${prefix}plan-phase 01 --gaps on ${id}, got: ${text}`,
|
|
);
|
|
assert.ok(!text.includes('/gsd:'), `deprecated colon form leaked: ${text}`);
|
|
} finally {
|
|
cleanup(projectDir);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// ─── #2868: stranded-phase detection via `verification status` ────────────────
|
|
//
|
|
// execute-phase's `discover_and_group_plans` step resumes at the phase gates
|
|
// when every plan is summarized but no *-VERIFICATION.md exists yet. That
|
|
// resume decision is driven by `gsd_run query verification status <phaseDir>
|
|
// --pick status` reading `missing`. These tests pin the CLI query's behavior
|
|
// on the exact fixture shapes the workflow branches on, via the real CLI
|
|
// (runGsdTools), not the in-process readVerificationStatus() helper used above.
|
|
describe('#2868: verification status CLI drives the execute-phase stranded-phase resume', () => {
|
|
const { runGsdTools, createTempGitProject } = require('./helpers.cjs');
|
|
|
|
test('D1: all plans summarized, no *-VERIFICATION.md → status is missing', () => {
|
|
const projectDir = createTempGitProject();
|
|
try {
|
|
const phaseDir = path.join(projectDir, '.planning', 'phases', '01-example');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
|
|
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary\n');
|
|
|
|
const res = runGsdTools(['verification', 'status', phaseDir, '--pick', 'status'], projectDir);
|
|
assert.equal(res.success, true, `verification status should succeed: ${res.error}`);
|
|
assert.equal(res.output, 'missing', 'no VERIFICATION.md at all → status must be missing');
|
|
} finally {
|
|
cleanup(projectDir);
|
|
}
|
|
});
|
|
|
|
test('D2: same fixture plus a passed *-VERIFICATION.md → status is not missing', () => {
|
|
const projectDir = createTempGitProject();
|
|
try {
|
|
const phaseDir = path.join(projectDir, '.planning', 'phases', '01-example');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
|
|
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary\n');
|
|
fs.writeFileSync(
|
|
path.join(phaseDir, '01-VERIFICATION.md'),
|
|
'---\nstatus: passed\n---\n\n# Verification\n',
|
|
);
|
|
|
|
const res = runGsdTools(['verification', 'status', phaseDir, '--pick', 'status'], projectDir);
|
|
assert.equal(res.success, true, `verification status should succeed: ${res.error}`);
|
|
assert.notEqual(res.output, 'missing', 'a passed VERIFICATION.md must not read as missing');
|
|
assert.equal(res.output, 'passed');
|
|
} finally {
|
|
cleanup(projectDir);
|
|
}
|
|
});
|
|
|
|
test('D3: one plan lacking a SUMMARY and no verification → still missing (not conflated with "stranded")', () => {
|
|
const projectDir = createTempGitProject();
|
|
try {
|
|
const phaseDir = path.join(projectDir, '.planning', 'phases', '01-example');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan 1\n');
|
|
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary 1\n');
|
|
// 01-02 has a PLAN but no SUMMARY — plan work is still outstanding, which is
|
|
// a different condition from the phase being "stranded" (all plans done,
|
|
// verification never ran). The query must not conflate the two.
|
|
fs.writeFileSync(path.join(phaseDir, '01-02-PLAN.md'), '# Plan 2\n');
|
|
|
|
const res = runGsdTools(['verification', 'status', phaseDir, '--pick', 'status'], projectDir);
|
|
assert.equal(res.success, true, `verification status should succeed: ${res.error}`);
|
|
assert.equal(
|
|
res.output,
|
|
'missing',
|
|
'outstanding plan work must not change verification status away from missing',
|
|
);
|
|
} finally {
|
|
cleanup(projectDir);
|
|
}
|
|
});
|
|
});
|
|
|
|
{
|
|
const { describe: __foldDescribe } = require('node:test');
|
|
__foldDescribe('folded:fix-3174-quick-verification-status-read', () => {
|
|
// allow-test-rule: source-text-is-the-product see #3174
|
|
// Workflow .md / agent .md / command .md / reference .md files — their text
|
|
// IS what the runtime loads. Testing text content tests the deployed contract.
|
|
// Per CONTRIBUTING.md exception matrix.
|
|
//
|
|
// #3174: quick's verification step used to read the verifier's result with a
|
|
// raw `grep "^status:" F | cut -d: -f2 | tr -d ' '` and route it through arms
|
|
// passed / human_needed / gaps_found only. That read failed two ways, both
|
|
// measured against the old pipeline: it matched NO arm on a missing report,
|
|
// most off-schema values, a `status:` line in both frontmatter and prose, or
|
|
// (on a CRLF checkout) a valid `passed` arriving as `passed\r`; and it
|
|
// matched the SUCCESS arm when it should not have on a stale `passed`
|
|
// report (staleness was never evaluated), a report whose only `status:` line
|
|
// sits in its prose, or an off-schema value carrying a colon
|
|
// (`passed:bogus`), which `cut -d: -f2` splits at that colon. The unanchored
|
|
// match is the DEFECT.FRONTMATTER-SCALAR-BROAD-GREP class the code side
|
|
// already fixed by name. These tests pin the five properties that keep the
|
|
// replacement honest.
|
|
describe('quick verification status read (#3174)', () => {
|
|
const QUICK_VERIFICATION = path.join(
|
|
__dirname, '..', 'gsd-core', 'workflows', 'quick', 'steps', 'quick-verification.md',
|
|
);
|
|
// The canonical launcher preamble. scripts/sync-runtime-launcher.cjs rewrites
|
|
// every workflow's bootstrap from this file, so THIS is the authority — not
|
|
// whichever sibling step file happens to carry a copy today.
|
|
const LAUNCHER_SNIPPET = path.join(
|
|
__dirname, '..', 'gsd-core', 'workflows', '_runtime-launcher.snippet.sh',
|
|
);
|
|
|
|
const SHIM_ANCHOR = '_GSD_SHIM_NAME="gsd-tools.cjs"';
|
|
|
|
test('status is read through the canonical query, not a raw frontmatter grep', () => {
|
|
const content = fs.readFileSync(QUICK_VERIFICATION, 'utf-8');
|
|
const queryIdx = content.indexOf('gsd_run query verification.status "${QUICK_DIR}"');
|
|
|
|
assert.ok(queryIdx !== -1, 'quick-verification.md must read status via the verification.status query');
|
|
assert.ok(
|
|
!content.includes('grep "^status:"'),
|
|
'the raw frontmatter-scalar grep must not return — it matches body lines too (DEFECT.FRONTMATTER-SCALAR-BROAD-GREP)',
|
|
);
|
|
});
|
|
|
|
test('the query call is preceded by the runtime shim bootstrap in this step file', () => {
|
|
// Step files are read and executed as their own units, so quick.md's
|
|
// bootstrap does not reach here. Without this the call resolves to
|
|
// nothing, 2>/dev/null swallows it, and the default arm is taken forever.
|
|
const content = fs.readFileSync(QUICK_VERIFICATION, 'utf-8');
|
|
const shimIdx = content.indexOf(SHIM_ANCHOR);
|
|
const queryIdx = content.indexOf('gsd_run query verification.status');
|
|
|
|
assert.ok(shimIdx !== -1, 'the step file must carry its own runtime shim bootstrap');
|
|
assert.ok(queryIdx > shimIdx, 'the shim bootstrap must precede the gsd_run call');
|
|
});
|
|
|
|
test('the shim bootstrap is the canonical launcher preamble, not a fork of it', () => {
|
|
// Anchored on _runtime-launcher.snippet.sh rather than on a sibling step
|
|
// file: sync-runtime-launcher.cjs regenerates every workflow from the
|
|
// snippet, so a synchronized launcher update keeps this green (correct),
|
|
// and a sibling that legitimately stops calling gsd_run cannot fail us.
|
|
const lineWithShim = (file) => fs.readFileSync(file, 'utf-8')
|
|
.split(/\r?\n/)
|
|
.find((line) => line.startsWith(SHIM_ANCHOR));
|
|
|
|
const mine = lineWithShim(QUICK_VERIFICATION);
|
|
const canonical = lineWithShim(LAUNCHER_SNIPPET);
|
|
|
|
assert.ok(canonical, '_runtime-launcher.snippet.sh must carry the canonical preamble');
|
|
assert.equal(mine, canonical, 'the bootstrap must match the canonical launcher snippet verbatim');
|
|
});
|
|
|
|
test('status extraction does not depend on jq', () => {
|
|
// #2589: a `| jq -r '.field'` pipe yields an empty variable with no
|
|
// diagnostic wherever jq is absent (the Windows/Git-Bash default), which
|
|
// would route a passing verification into the recovery arm.
|
|
//
|
|
// Scoped to the executable fence on purpose: the surrounding prose cites
|
|
// the jq form in order to explain why it is not used, and an assertion
|
|
// over the whole file would fire on its own rationale.
|
|
const content = fs.readFileSync(QUICK_VERIFICATION, 'utf-8');
|
|
// eslint-disable-next-line local/no-unbounded-quantifier -- parses this repo's own workflow .md content, fixed-size author-controlled content
|
|
const fences = content.match(/```bash\r?\n[\s\S]*?```/g) || [];
|
|
const statusFence = fences.find((f) => f.includes('gsd_run query verification.status'));
|
|
|
|
assert.ok(statusFence, 'the status read must live in a bash fence');
|
|
assert.ok(
|
|
statusFence.includes('--pick status'),
|
|
'the bare status must be picked by the query itself',
|
|
);
|
|
assert.ok(!/\|\s*jq\b/.test(statusFence), 'the status-read fence must not pipe through jq');
|
|
});
|
|
|
|
test('the routing table carries a terminal arm for missing / unknown / stale', () => {
|
|
const content = fs.readFileSync(QUICK_VERIFICATION, 'utf-8');
|
|
const gapsIdx = content.indexOf('| `gaps_found` |');
|
|
const fallbackIdx = content.indexOf('| anything else');
|
|
|
|
assert.ok(gapsIdx !== -1, 'the three verifier-status arms must remain');
|
|
assert.ok(fallbackIdx > gapsIdx, 'a terminal arm must follow the verifier-status arms');
|
|
|
|
const fallbackRow = content.slice(fallbackIdx, content.indexOf('\n', fallbackIdx));
|
|
for (const sentinel of ['missing', 'unknown', 'stale']) {
|
|
assert.ok(
|
|
fallbackRow.includes(sentinel),
|
|
`the terminal arm must name the ${sentinel} sentinel the query can return`,
|
|
);
|
|
}
|
|
assert.ok(
|
|
fallbackRow.includes('VERIFICATION_STATUS'),
|
|
'the terminal arm must set the display string consumed by the quick index row and banner',
|
|
);
|
|
});
|
|
});
|
|
});
|
|
}
|