* fix(#3951): two lint rules that could not reach the code they govern B6 names two widenings. Measuring them first turned up a defect the criterion did not know about, and refuted the reason it gave for one of them. 1. no-adhoc-markdown-parsing self-gates on its own filename. Lines 107-110 short-circuit create() to {} unless the path matches /(?:^|\/)src\/[^/]+\.cts$/. B6 says to widen the files: glob in eslint.config.mjs - but doing only that ships an INERT rule, because the gate still returns {} for every new path. Both halves have to change, and the gate is the load-bearing one. That same regex hides a live hole: [^/]+ is FLAT-ONLY, so it requires the file to sit directly in src/. The registered glob is src/**/*.cts, which includes subdirectories. 28 .cts files - health-diagnostic-rules/ (10), installer-migrations/ (11), observability/ (3), host-integration-adapters/ (2), vendor/ (2) - are inside the registered glob and silently skipped. Measured with the gate neutralized: 0 violations there today. The hole is hiding nothing right now, and is fixed anyway, because "no violations today" is not a property that keeps holding. The fix is not invented: require-subprocess-timeout.cjs:196 already carries the correct form of this guard, /(?:^|\/)src\/.*\.cts$/ with .*, one directory over. Checked the other 21 rules for the same bug - no-adhoc-regex-escape and no-private-binary-resolution short-circuit only to exempt their own seam file, which is the right shape, and no-crlf-fragile-split has no filename gate at all. This bug is unique to the one rule. 2. no-adhoc-regex-escape could not see the shape that actually occurs. Line 396 gated the whole UNSAFE-NEW-REGEXP arm on arg.type === 'Identifier'. Every check below it - the _SOURCE provenance check, the isSoleReturnOfOwnParameter shape - lives inside that branch, so new RegExp(obj['key']) and new RegExp(cfg.pattern) were never examined at all. Runtime data arrives as a property access far more often than as a bare identifier, which is exactly why this rule never fired on the #3477 ReDoS. Widened to MemberExpression, measured by AST walk across all five registered blocks rather than by grep. 27 sites, zero TSAsExpression: 18 safe new RegExp(X.source, flags) -> exempted, keyed strictly on the PROPERTY being `source`, never on the object. Keying on the object would wave through X.anything and buy nothing. B6 estimated ~10; that was an undercount. 3 _SOURCE-suffixed constants reached through a required module namespace (phaseId.BRACKET_PHASE_TOKEN_SOURCE) -> the same provenance-exempt class the rule already recognizes for bare identifiers, extended to reach them. Without this the widening produces 3 false flags. 6 real findings -> marked, each a test extracting a pattern from a shipped file at test time, where the runtime contract IS the product. Deliberately the NARROW MemberExpression form. The rule's own isSoleReturnOfOwnParameter doc comment records that an earlier broad "any non-literal identifier" heuristic produced ~25 false positives and was rejected; a re-run of the census after this change flags exactly the 6 above and nothing else. Verified by execution, not by reading: the gate now accepts src/<subdir>/x.cts, still accepts flat src/x.cts, and still exempts paths outside src/ - each pinned by a test proven to fail against the old regex. build:lib, lint and lint:ci all exit 0. Refs #3951 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3951): give no-adhoc-markdown-parsing its reach, and fix the 80 parses it finds The rule self-gates on filename AND is registered on one glob, so widening either half alone is inert. Both move here: the gate now accepts tests/**/*.cjs and scripts/**/*.cjs alongside src/**/*.cts, and eslint.config.mjs registers it on the same two. A test pins that the gate and the registration AGREE, in both directions. The original defect was a gate narrower than its registration; the failure mode of this fix is a gate wider than its registration. Both are silent, so the test asserts the pair rather than either half. 80 violations across 43 files, all in tests/, zero in scripts/. 70 are routed through the existing seams - scanFencedBlocks, collectSection, stripFencedCode, tokenizeHeadings from markdown-sectionizer; splitTableRow, parseMarkdownTable, findTableWithColumns from markdown-table. Headerless STATE.md tables use splitTableRow per line, because parseMarkdownTable needs a real delimiter row. 10 are suppressed, 12.5%, well under the third that would have meant the rule is mis-scoped for tests/ rather than the tests carrying debt. Each names its reason: three regression guards (#3873 / bug-#21) are deliberately independent of the generator's own fence handling, and routing them through the seam would have them test the generator against itself; one is a negative-text probe that extracts nothing; six are a shell-pipe-to-jq detector whose regex coincidentally matches the table fingerprint and is not markdown parsing at all. All ten sit in tests whose subject is .md content, which is normally a reason to prefer the seam. The marker used is allow-adhoc-markdown, distinct from no-source-grep's allow-test-rule, and lint:ci's lint-allow-test-rule-refs reports the same 280/280 unverified count as before - checked rather than assumed, because those two markers are easy to conflate. The widening earned its keep immediately: it found a test that passed for the wrong reason. tests/config-field-docs.test.cjs asserted notEqual(<cell>, '600') against the TYPE column instead of the DEFAULT column. notEqual('number', '600') is true forever, so the guard against workflow.subagent_timeout regressing to the old seconds default could never fire. docs/CONFIGURATION.md:434 is `| workflow.subagent_timeout | number | 300000 | ... |`, so the default is cell index 2; the assertion is now row-scoped through splitTableRow and reads 300000. That is the argument for the widening in one case: the violation was invisible to lint, the suite was green, and the assertion was vacuous. A rule that cannot reach a file cannot tell you the file is lying. Not fixed here, and recorded rather than assumed: #3426/#3239 are NOT reachable by this widening. tests/package-legitimacy-gate.test.cjs yields zero violations even with the gate bypassed - its hand-rolled scans are real, but built from line filters and split('|') rather than the regex-literal fingerprints this rule detects. They need new detectors. The epic assumed a wider glob would catch them. build:lib, lint and lint:ci all exit 0; the post-fix census across tests/** and scripts/** is 0 violations. Refs #3951 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3951): B7 — and #3356's defects were still live in the code B7 asks that each closed child be driven fail-first with a behavioral identity test at the CONSUMER's output. Four of eleven children had no test citing their issue number. Auditing them by BEHAVIOR rather than by number-grep changed the answer for three of the four. #3364 and #2540 — traceability only. Both were implemented by #3941 and their consumer-output tests exist and were shown failing-first; neither cited its originating issue, so an audit that greps for the number reports them uncovered. Tagged the specific asserting test in each file, following the citation form those files already use. #3372 — covered, but only at helper level, and the triage narrowed it. Of the four commands the issue names, only estimate-cli's collectCalibrationSamples actually enumerates phase dirs from disk; smart-entry, audit and roadmap-upgrade derive from ROADMAP/body text and never reach the sentinel path, so they are benign by construction and were left alone rather than "fixed" into churn. The existing #3882 rows asserted the helper's return value. Added a consumer-output test driving `query estimate-calibrate` and asserting sample_count and the persisted document. RED proof: reverted collectCalibrationSamples to a raw readdirSync and ran the real CLI - sample_count 3, sentinel leaked; restored - sample_count 2. #3356 — NOT covered, and BOTH halves of the defect were still live in source. The issue is closed; the bug was not fixed. Fixed here rather than writing tests that document a bug as correct. Defect 1, the contradicted row. quick.md:627 claimed `quick-tasks-append` performs "the equivalent write" to the Step 7c row. It did not: the `#` cell was a positional ordinal and `Directory` read `—`, because the route had no way to receive a quick id or task directory. Added OPTIONAL `--quick-id` / `--slug` / `--directory`. A caller with neither - fast.md, the original #2133 caller - omits them and gets the byte-identical prior row, so nothing existing changes. A caller that HAS a real id and directory now gets the canonical row quick.md:632 renders. The false-equivalence sentence itself is corrected rather than left to mislead the next reader. Defect 2, the forced re-derive. The route called readModifyWriteStateMd with no options, so a body-only append to the Quick Tasks table triggered a full re-derive of the disk-derived progress.* frontmatter. Every other body-only writer passes { resync: false } - src/state.cts's own docstring prescribes it - and this route was the lone outlier. RED proof: reverted the option, seeded a project with 2 real phase dirs and a curated total_phases of 25, ran quick-tasks-append; total_phases collapsed to 2. Restored; it stayed 25. That second one is the shape this epic exists to close: a silent write that replaces curated state with a re-derivation nobody asked for, exit 0 throughout. build:lib, lint and lint:ci all exit 0. Refs #3951 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(#3951): amend B6's ledger to what was measured, and document the new flags The ADR gains a ledger amendment in its own correction style - the sixth wrong premise it records, found the same way as the other five, by measuring before building. B6 says the net guard count must fall. It rose: 62 -> 69, +7, measured from the epic's filing commit to origin/next. The attribution is the point, though. Five of the seven came from PRs unrelated to this epic, one was added by a phase of it, and the epic did retire something sub-file - #3884 removed a detector with an explicit "net: -1 detector, 0 added" ledger. Every named casualty is load-bearing, two already carry retractions in this same document, and a sweep of all 22 rules plus every scripts/lint-* found no provably dead guard. There is no honest way to make the count fall; forcing it would trade coverage for a number, which is the Goodhart outcome Decision 6 exists to prevent. The amendment also records that B6's own prescribed fix for one widening was inert. no-adhoc-markdown-parsing self-gates on its filename, so widening only the files: glob - which is what the criterion says to do - ships a rule that still returns {} for every new path. And #3426/#3239 are not reachable by that widening at all; their scans use line filters and split('|'), not the regex fingerprints the rule detects. The roster row tracked them against the wrong mechanism. Three roster rows updated from aspiration to fact: the two widenings are DONE with their measured counts, and lint-phase-enumeration-drift is marked RETAINED rather than "expected casualty - verify before retiring", because Phase 5 verified it and kept it. The rule Decision 6 should carry forward is stated plainly: a guard ledger is a claim about COVERAGE, not about COUNT. "Net count must fall" is measurable and wrong. "Every guard is reachable, and each retirement names what makes its defect unrepresentable" is the property that was actually wanted. CLI-TOOLS.md documents the optional --quick-id/--slug/--directory flags and says plainly that omitting them keeps the pre-#3356 row byte-identical, plus that the append no longer re-derives progress frontmatter. New features fragment (id 3951); FEATURES.md regenerated rather than hand-edited. Changeset is Changed, pr:0 pending backfill. Refs #3951 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#3951): correct four rows that pinned the lint rule's old narrow reach The remote suite came back RED with 5 failures, all in tests/eslint-rules.test.cjs. They are stale tests, not a regression: four rows assert that no-adhoc-markdown-parsing is inert outside src/*.cts, which is exactly the contract this deliverable changes. Confirmed by reading rather than inferred from the names - the row at :1981 used filename: 'tests/some.test.cjs' and filename: 'scripts/helper.cjs', the two roots the rule now covers on purpose. Worth recording WHY local gates missed this. npm run lint and lint:ci were green, and the touched test files passed standalone. Lint only reports violations in real files; these rows assert the rule's REACH using synthetic RuleTester filenames, so nothing but the full suite could see them. Local green on a rule change says nothing about the rule's own tests. Each row is rewritten with BOTH halves rather than flipped from valid to invalid: - the same fingerprint under tests/ or scripts/ is now flagged, with the right messageId - the negative space is preserved - the same fingerprint under a path outside all three roots (gsd-core/bin/lib/foo.cjs) is still NOT flagged The second half is the one that matters. Without it the rule has no boundary and nothing would catch an over-wide gate later, which is the mirror image of the bug this deliverable just fixed. Each row is renamed to state the current contract; the old names said "non-src/*.cts ... is not flagged" and would have been actively misleading once the bodies changed. Proven to test the widening rather than restate it: every flagged half was run against HEAD~2's pre-widening rule and does NOT fire there, then against the current rule and does. 12/12 on that probe; the full file is 178/178. Swept for the same staleness elsewhere and found none. require-subprocess-timeout's own "inert outside src/*.cts" row is untouched - that rule's gate was not widened here - and no-adhoc-regex-escape's test file already carries correctly-targeted rows. Refs #3951 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#3951): acknowledge the quick.md growth the attribution guard reported The full suite came back RED with one failure, and it is mine: 1 file(s) grew without an acknowledgment: quick.md grew 364 bytes gsd-core/workflows/quick.md is runtime-loaded emitted content, so correcting its false 'performs the equivalent write' claim trips emitted-attribution by construction. This is the acknowledgment, not a workaround - there is nothing to regenerate. The fragment names ONE path, which is the only one the guard reported. The four spent acknowledgments it also listed (audit-uat, plan-phase, progress, review) belong to other fragments whose ripple the base already absorbs; they are inert, not failures, and are deliberately NOT copied here - naming paths I did not change would make this record false in the other direction. Byte figure corrected before committing: the guard reported 37220 -> 37584 (+364), but origin/next has since moved and quick.md is 37232 there now, so the measured delta is +352. The reason text says so and names the base as a moving figure rather than pinning a number that is already stale. Refs #3951 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#3951): move the quick.md growth ack to a trailer, delete the obsolete fragment The acknowledgment mechanism changed under this branch. Merging next brought in the redesign - it also deleted .github/workflows/ack-fragment-sweep.yml, which was in the merge status and which I did not register at the time - and the guard now says so directly: Add a trailer to a commit in this PR (never a new file). Emitted-Drift-Ack-Growth: quick.md - <why this growth is deliberate> So tests/emitted-drift-acks/3951-quick-append-equivalence.json is obsolete on arrival. A fragment file is no longer read by anything, and leaving it would be a dead record that looks like an active one. It is deleted here rather than kept "just in case". The byte figure moved again with the merge: 37232 -> 37596, +364. The earlier fragment said +352, measured before the merge auto-merged quick.md itself. The trailer carries no number, which is the better design - the figure was stale twice in two attempts. Refs #3951 Emitted-Drift-Ack-Growth: quick.md — #3356/#3951 replaces a false claim with an accurate one. Line 627 said the `quick-tasks-append` shortcut "performs the equivalent write" to the Step 7c row rendered above it; it did not, and that was the documented half of #3356 — with no quick id or task directory the route emitted a positional ordinal in `#` and an em-dash in `Directory`, a visibly different row. The corrected sentence has to carry three facts the original elided: what the shortcut actually writes when it has neither input, that this is honest behavior for its real caller (`fast.md`, which has neither), and how a caller with both now gets the byte-identical canonical row via the new optional `--quick-id`/`--slug`/`--directory` flags. Prose is the product here — an executing agent reads this line to decide whether the shortcut is safe for its case, and a shorter correction would either drop the flags (leaving the reader unable to act on the fix) or drop the limitation (recreating the false claim in gentler words). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#3951): backfill changeset pr number Refs #3951 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1905 lines
86 KiB
JavaScript
1905 lines
86 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, none matching the phase's own token →
|
|
* alphabetically-first FALLBACK wins (the phase-pinned rule's #2 tier —
|
|
* see #3492 below for the primary, phase-pinned tier)
|
|
* 12. ship.md PHASE_VERIFICATION_INCOMPLETE sentinel (contract anchor for #651 consolidation)
|
|
* 13. #3357/#3492: `<phase-token>-VERIFICATION.md` resolution — resolveVerificationFile
|
|
* unit coverage plus behavioral tests through readVerificationStatus and
|
|
* findStaleVerificationSummary. THE CONTRACT (#3492): a candidate whose
|
|
* name exactly matches THIS phase's own token always wins, even over a
|
|
* different phase's canonically-shaped file; alphabetical-first among all
|
|
* dashed candidates is only the fallback when no exact match exists. The
|
|
* resolveVerificationFile unit tests are the reliable anchors for this —
|
|
* the readVerificationStatus/findStaleVerificationSummary behavioral tests
|
|
* are illustrative (their outcome also depends on directory-basename
|
|
* token derivation, not exercised in isolation there).
|
|
*
|
|
* 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 { scanFencedBlocks } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs');
|
|
|
|
const {
|
|
VERIFIER_STATUSES,
|
|
VERIFICATION_ROUTING_TABLE,
|
|
defaultPhaseCleanCommitTimesMs,
|
|
resolveVerificationFile,
|
|
resolveUatFile,
|
|
readVerificationStatus,
|
|
findStaleVerificationSummary,
|
|
} = 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 named like a real one (`NN-slug`),
|
|
* inside a throwaway parent. #3511: a phase directory's own name determines
|
|
* which files count as ITS artifacts, so a fixture whose basename does not
|
|
* name the same phase as the files written into it is not a valid phase dir.
|
|
* @param {string} suffix - test-distinguishing suffix for the parent
|
|
* @param {string} phaseDirName - basename of the phase dir (default '01-foo')
|
|
*/
|
|
function mkPhaseDir(suffix, phaseDirName = '01-foo') {
|
|
const parent = fs.mkdtempSync(path.join(os.tmpdir(), `gsd-651-${suffix}-`));
|
|
const phaseDir = path.join(parent, phaseDirName);
|
|
fs.mkdirSync(phaseDir);
|
|
return phaseDir;
|
|
}
|
|
|
|
/**
|
|
* 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(path.dirname(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', () => {
|
|
// Deliberately non-numeric dir basename ("human-needed" has no digits at
|
|
// all) — extractPhaseToken has no derivable token, so (a) isPhaseArtifact's
|
|
// fail-safe still includes 01-hn-VERIFICATION.md as this "phase"'s own
|
|
// report, and (b) the next_command number-append check (which requires a
|
|
// PURELY numeric token) never fires. This is what this test is actually
|
|
// pinning — see the comment below — so the dir name must stay non-numeric,
|
|
// not the realistic 'NN-slug' default.
|
|
const dir = mkPhaseDir('human-needed', '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(path.dirname(dir));
|
|
}
|
|
});
|
|
|
|
// ── Case 4: no *-VERIFICATION.md → missing ────────────────────────────────
|
|
test('no *-VERIFICATION.md file → status missing, next_command execute-phase', () => {
|
|
// Non-numeric dir basename: next_command asserts no phase-number argument
|
|
// is appended, which requires extractPhaseToken(dirName) to not be purely
|
|
// numeric — see the human_needed test above for the same rationale.
|
|
const dir = mkPhaseDir('missing', '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(path.dirname(dir));
|
|
}
|
|
});
|
|
|
|
// ── Case 5: unknown frontmatter status value ──────────────────────────────
|
|
test("frontmatter status 'bogus' → status unknown, next_command execute-phase", () => {
|
|
// Non-numeric dir basename: next_command asserts no phase-number argument
|
|
// is appended — see the human_needed test above for the same rationale.
|
|
const dir = mkPhaseDir('unknown', '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(path.dirname(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(path.dirname(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(path.dirname(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(path.dirname(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, NEITHER matching the phase dir's own
|
|
// token → deterministic FALLBACK pick (first by sort). This is the #2 tier
|
|
// of the #3492 phase-pinned rule, not the contract itself — see the
|
|
// `#3357/#3492` describe block below for the primary, phase-pinned tier
|
|
// (resolveVerificationFile unit tests are the reliable anchors there).
|
|
// The dir basename ('multi', no digits) has no derivable phase token, so
|
|
// scopeToPhase's isPhaseArtifact fail-safe passes BOTH candidates through
|
|
// unfiltered (#3511: scopeToPhase is a plain filter with no other
|
|
// fallback — a derivable token that matched neither file would empty the
|
|
// set and this test would read 'missing', not exercise the alphabetical
|
|
// tiebreak at all).
|
|
test('multiple *-VERIFICATION.md files, none matching the phase token → alphabetically-first FALLBACK wins', () => {
|
|
const dir = mkPhaseDir('multi', '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',
|
|
'With no exact phase-token match, the first by lexicographic sort must be used',
|
|
);
|
|
} finally {
|
|
cleanup(path.dirname(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.',
|
|
);
|
|
});
|
|
|
|
});
|
|
|
|
// ─── #3357/#3492: phase-pinned *-VERIFICATION.md resolution ──────────────────
|
|
//
|
|
// A phase dir can legitimately hold more than one `*-VERIFICATION.md` — the
|
|
// real per-phase report (`03-VERIFICATION.md`) alongside an ad-hoc plan
|
|
// worksheet (`03-CORRECTION-VERIFICATION.md`). The original "alphabetically
|
|
// first" pick chose the worksheet ('C' < 'V'), and a worksheet with no
|
|
// frontmatter `status:` made the whole phase read `missing` even though a
|
|
// passing report sat right next to it (#3357).
|
|
//
|
|
// #3492 REGRESSION this block anchors: the #3357 fix's first cut preferred
|
|
// ANY canonically-shaped `<token>-VERIFICATION.md`, regardless of WHOSE token
|
|
// it carried — so a stray cross-phase or sentinel-numbered canonical file
|
|
// (`999-VERIFICATION.md`) could outrank the querying phase's own (possibly
|
|
// non-canonical) report. THE CONTRACT (verified against the built lib):
|
|
// ['12-review-VERIFICATION.md', '999-VERIFICATION.md'] resolves to
|
|
// '12-review-VERIFICATION.md' for phase token '12' (was '999-…').
|
|
// ['03-CORRECTION-VERIFICATION.md', '04-VERIFICATION.md'] resolves to
|
|
// '04-VERIFICATION.md' for phase token '04' (was '03-CORRECTION-…').
|
|
// resolveVerificationFile is the single resolver findStaleVerificationSummary,
|
|
// readVerificationStatus, commands.cts's determinePhaseStatus, and both
|
|
// init.cts verification_path projectors all call, every one pinned to its own
|
|
// phaseDir's token (#3473 F2 / #3492).
|
|
//
|
|
// These resolveVerificationFile unit tests are the RELIABLE ANCHORS for the
|
|
// phase-pinned rule (a real `phaseToken` string, no filesystem/readdir order
|
|
// involved). The readVerificationStatus/findStaleVerificationSummary
|
|
// behavioral tests further down are illustrative only — their outcome
|
|
// additionally depends on the temp directory's basename tokenizing the way
|
|
// the test expects.
|
|
describe('#3357/#3492: phase-pinned *-VERIFICATION.md resolution when multiple candidates exist', () => {
|
|
|
|
test('#3492 regression counterexample 1: a sentinel-numbered stray file does not outrank this phase\'s own non-canonical report', () => {
|
|
assert.equal(
|
|
resolveVerificationFile(
|
|
['12-review-VERIFICATION.md', '999-VERIFICATION.md'],
|
|
{ phaseToken: '12-review' },
|
|
),
|
|
'12-review-VERIFICATION.md',
|
|
'this phase (token "12-review") owns 12-review-VERIFICATION.md; 999-VERIFICATION.md is a different phase and must not win',
|
|
);
|
|
});
|
|
|
|
test('#3492 regression counterexample 2: a cross-phase CORRECTION worksheet does not outrank this phase\'s own canonical report', () => {
|
|
assert.equal(
|
|
resolveVerificationFile(
|
|
['03-CORRECTION-VERIFICATION.md', '04-VERIFICATION.md'],
|
|
{ phaseToken: '04' },
|
|
),
|
|
'04-VERIFICATION.md',
|
|
'this phase (token "04") owns 04-VERIFICATION.md; the 03-CORRECTION worksheet belongs to a different phase',
|
|
);
|
|
});
|
|
|
|
test('exact phase-token match wins over an ad-hoc -CORRECTION- worksheet for the SAME phase', () => {
|
|
assert.equal(
|
|
resolveVerificationFile(
|
|
['03-CORRECTION-VERIFICATION.md', '03-VERIFICATION.md'],
|
|
{ phaseToken: '03' },
|
|
),
|
|
'03-VERIFICATION.md',
|
|
'the phase\'s own 03-VERIFICATION.md must win over its CORRECTION worksheet, not lose alphabetically',
|
|
);
|
|
});
|
|
|
|
test('order-independence: same candidates reversed → same answer', () => {
|
|
assert.equal(
|
|
resolveVerificationFile(
|
|
['03-VERIFICATION.md', '03-CORRECTION-VERIFICATION.md'],
|
|
{ phaseToken: '03' },
|
|
),
|
|
'03-VERIFICATION.md',
|
|
'input order must not change which file is selected',
|
|
);
|
|
});
|
|
|
|
test('decimal phase token: 35.1-VERIFICATION.md wins over a -CORRECTION- sibling', () => {
|
|
assert.equal(
|
|
resolveVerificationFile(
|
|
['35.1-CORRECTION-VERIFICATION.md', '35.1-VERIFICATION.md'],
|
|
{ phaseToken: '35.1' },
|
|
),
|
|
'35.1-VERIFICATION.md',
|
|
);
|
|
});
|
|
|
|
test('letter-suffixed phase token: 03A-VERIFICATION.md wins over a -CORRECTION- sibling', () => {
|
|
assert.equal(
|
|
resolveVerificationFile(
|
|
['03A-CORRECTION-VERIFICATION.md', '03A-VERIFICATION.md'],
|
|
{ phaseToken: '03A' },
|
|
),
|
|
'03A-VERIFICATION.md',
|
|
);
|
|
});
|
|
|
|
test('multi-canonical tiebreak: no exact phase-token match among several canonically-shaped candidates → alphabetically first', () => {
|
|
// Neither candidate's token is "50" — this is the (b) fallback tier, and
|
|
// it must stay a plain alphabetical pick (not a second, separate
|
|
// "canonical-shaped" preference — that concept no longer exists; #3492
|
|
// removed it because it was exactly the regression mechanism above).
|
|
assert.equal(
|
|
resolveVerificationFile(
|
|
['12-VERIFICATION.md', '999-VERIFICATION.md'],
|
|
{ phaseToken: '50' },
|
|
),
|
|
'12-VERIFICATION.md',
|
|
'"12-VERIFICATION.md" sorts before "999-VERIFICATION.md" and neither matches phase token "50"',
|
|
);
|
|
});
|
|
|
|
test('fallback: only a non-canonical file present → that file is still returned', () => {
|
|
// Load-bearing: a phase whose only report is non-canonically named must
|
|
// keep resolving to it, not to null — even when the phase token is known
|
|
// and does not exactly match.
|
|
assert.equal(
|
|
resolveVerificationFile(['01-review-VERIFICATION.md'], { phaseToken: '01' }),
|
|
'01-review-VERIFICATION.md',
|
|
);
|
|
});
|
|
|
|
test('fallback determinism: several non-canonical files, no phase token given → alphabetically first (unchanged)', () => {
|
|
assert.equal(
|
|
resolveVerificationFile(['02-b-VERIFICATION.md', '01-a-VERIFICATION.md']),
|
|
'01-a-VERIFICATION.md',
|
|
);
|
|
});
|
|
|
|
test('no phaseToken and no exact match → falls back to alphabetically-first, never null, when candidates exist', () => {
|
|
// #3492: an undeliverable/absent phase token must degrade to the original
|
|
// pre-#3357 behavior (alphabetically-first), not to null.
|
|
assert.equal(
|
|
resolveVerificationFile(['999-VERIFICATION.md', '03-CORRECTION-VERIFICATION.md']),
|
|
'03-CORRECTION-VERIFICATION.md',
|
|
'with no phaseToken, plain alphabetical order decides — "03-…" sorts before "999-…"',
|
|
);
|
|
});
|
|
|
|
test('no matches → null', () => {
|
|
assert.equal(resolveVerificationFile(['03-PLAN.md', '03-SUMMARY.md'], { phaseToken: '03' }), null);
|
|
});
|
|
|
|
test('unrelated files are not miscounted as candidates', () => {
|
|
// 03-PLAN.md / 03-SUMMARY.md never end in "-VERIFICATION.md". A bare
|
|
// "VERIFICATION.md" (no leading phase-token dash) is also never a
|
|
// candidate — it fails the very `.endsWith('-VERIFICATION.md')` filter
|
|
// that builds the candidate list in the first place (the string is one
|
|
// character too short to end with a leading-dash suffix).
|
|
assert.equal(
|
|
resolveVerificationFile(['03-PLAN.md', '03-SUMMARY.md', 'VERIFICATION.md'], { phaseToken: '03' }),
|
|
null,
|
|
'a bare VERIFICATION.md is never a dashed candidate',
|
|
);
|
|
});
|
|
|
|
// #3511 reconciliation: resolveVerificationFile's fallback now scopes to
|
|
// isPhaseArtifact(fileName, phaseDirName), so a stray cross-phase file can
|
|
// no longer win the alphabetical-first fallback tier either — closing the
|
|
// gap isPhaseArtifact's own docblock (src/phase-id.cts) used to flag as
|
|
// open. The two pure cases below are the reliable anchors; the behavioral
|
|
// test after them pins the same contract through the real CLI-facing
|
|
// readVerificationStatus call path.
|
|
test('#3511: a cross-phase stray is excluded from the fallback → null, not the stray', () => {
|
|
assert.equal(
|
|
resolveVerificationFile(['04-VERIFICATION.md'], { phaseDirName: '03-foo' }),
|
|
null,
|
|
'04-VERIFICATION.md belongs to phase 04, not the "03-foo" directory\'s phase 03 — must not be returned',
|
|
);
|
|
});
|
|
|
|
test('#3511: a non-canonically-named report OF THIS phase still wins the fallback (the #3357 guarantee survives)', () => {
|
|
// The more important of the two #3511 cases: isPhaseArtifact scopes by
|
|
// phase-number membership, not by canonical shape, so this file still
|
|
// passes and the #3357 "non-canonical report still resolves" guarantee
|
|
// is not disturbed by the #3511 scoping.
|
|
assert.equal(
|
|
resolveVerificationFile(['03-CORRECTION-VERIFICATION.md'], { phaseDirName: '03-foo' }),
|
|
'03-CORRECTION-VERIFICATION.md',
|
|
'03-CORRECTION-VERIFICATION.md names phase 03, same as directory "03-foo" — must still resolve',
|
|
);
|
|
});
|
|
|
|
test('#3511: cross-phase stray alongside this phase\'s own non-canonical report → own report wins, stray excluded (not merely outsorted)', () => {
|
|
// Distinguishes "excluded from the fallback" from "just happens to sort
|
|
// after" — candidates are sorted at verification.cts's own call site
|
|
// before reaching resolveVerificationFile, and '01-VERIFICATION.md'
|
|
// sorts BEFORE '03-CORRECTION-VERIFICATION.md' alphabetically, so an
|
|
// UNSCOPED (alphabetical-first) fallback would wrongly pick the stray
|
|
// here. Scoping must actively exclude it for '03-CORRECTION-…' to win.
|
|
assert.equal(
|
|
resolveVerificationFile(
|
|
['01-VERIFICATION.md', '03-CORRECTION-VERIFICATION.md'],
|
|
{ phaseDirName: '03-foo' },
|
|
),
|
|
'03-CORRECTION-VERIFICATION.md',
|
|
);
|
|
});
|
|
|
|
// WARNING-2/5/INFO-2 note (#3511 review): the only fallback test above uses
|
|
// a token-LESS dir ("03-foo" isn't token-less — this refers to the earlier
|
|
// `multiple *-VERIFICATION.md files, none matching the phase token` test,
|
|
// which passes no derivable-token distinguishing fixture and so passes
|
|
// identically pre-#3511-fix). This test uses a dir WITH a derivable token
|
|
// (`03-foo` → token "03") and TWO candidates that BOTH belong to that same
|
|
// phase (`03-a-…`/`03-b-…`, no exact `03-VERIFICATION.md`), so scoping
|
|
// excludes nothing and the alphabetical-first tie-break still decides —
|
|
// pinning that scoping does not disturb the ordinary same-phase-multi-file
|
|
// case.
|
|
test('#3511: alphabetical fallback when BOTH candidates are this phase\'s own (derivable token, no exact match)', () => {
|
|
assert.equal(
|
|
resolveVerificationFile(['03-a-VERIFICATION.md', '03-b-VERIFICATION.md'], { phaseDirName: '03-foo' }),
|
|
'03-a-VERIFICATION.md',
|
|
'both candidates belong to phase 03 (same as dir "03-foo"); alphabetically-first must still win',
|
|
);
|
|
});
|
|
|
|
test('behavioral (readVerificationStatus): a phase dir holding only a cross-phase stray reports missing, not the stray\'s status (#3511)', () => {
|
|
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3511-stray-only-'));
|
|
const dir = path.join(baseDir, '03-foo');
|
|
fs.mkdirSync(dir);
|
|
try {
|
|
// Only a stray belonging to phase 04 sits in phase 03's directory. Give
|
|
// it a status that would NOT read as missing if it were (wrongly) picked,
|
|
// so a regression here is loud rather than accidentally matching.
|
|
writeVerificationMd(dir, '04-VERIFICATION.md', 'passed');
|
|
|
|
const result = readVerificationStatus(dir);
|
|
assert.equal(
|
|
result.status,
|
|
'missing',
|
|
'a phase dir holding only another phase\'s report must report missing, not passed',
|
|
);
|
|
} finally {
|
|
cleanup(baseDir);
|
|
}
|
|
});
|
|
|
|
test('behavioral (readVerificationStatus): a phase dir holding only its own non-canonically-named report still resolves it (#3357 guarantee survives #3511 scoping)', () => {
|
|
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3511-own-noncanon-'));
|
|
const dir = path.join(baseDir, '03-foo');
|
|
fs.mkdirSync(dir);
|
|
try {
|
|
writeVerificationMd(dir, '03-CORRECTION-VERIFICATION.md', 'passed');
|
|
|
|
const result = readVerificationStatus(dir);
|
|
assert.equal(
|
|
result.status,
|
|
'passed',
|
|
'the phase\'s own non-canonically-named report must still resolve, not read as missing',
|
|
);
|
|
} finally {
|
|
cleanup(baseDir);
|
|
}
|
|
});
|
|
|
|
test('behavioral (readVerificationStatus): a phase with both its own report and a cross-phase stray reports the OWN report\'s status, not the stray\'s', () => {
|
|
// The directory basename is "03-canonical-test" so extractPhaseToken
|
|
// derives token "03" — the exact same derivation readVerificationStatus
|
|
// performs internally, so this exercises the real production call path
|
|
// (not just the pure resolver), pinned to counterexample 2's shape.
|
|
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3492-parent-'));
|
|
const dir = path.join(baseDir, '03-canonical-test');
|
|
fs.mkdirSync(dir);
|
|
try {
|
|
// A stray cross-phase canonical file with a DIFFERENT status — must not
|
|
// be picked for THIS (token "03") phase.
|
|
writeVerificationMd(dir, '99-VERIFICATION.md', 'gaps_found');
|
|
// This phase's own (non-canonical, ad-hoc) report — must win.
|
|
writeVerificationMd(dir, '03-CORRECTION-VERIFICATION.md', 'passed');
|
|
|
|
const result = readVerificationStatus(dir);
|
|
assert.equal(
|
|
result.status,
|
|
'passed',
|
|
'the phase must report its OWN report\'s status, not a cross-phase stray\'s',
|
|
);
|
|
} finally {
|
|
cleanup(baseDir);
|
|
}
|
|
});
|
|
|
|
test('behavioral (readVerificationStatus): a phase with both its own canonical report and an ad-hoc worksheet reports the canonical report\'s status, not missing (#3357 original regression)', () => {
|
|
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3357-parent-'));
|
|
const dir = path.join(baseDir, '03-canonical-test');
|
|
fs.mkdirSync(dir);
|
|
try {
|
|
// The ad-hoc worksheet has no frontmatter `status:` at all — this is
|
|
// the exact original #3357 failure mode: 'C' < 'V' picked this file
|
|
// first and the phase read 'missing' despite the passing report sitting
|
|
// right next to it.
|
|
fs.writeFileSync(
|
|
path.join(dir, '03-CORRECTION-VERIFICATION.md'),
|
|
'# Ad-hoc correction worksheet\n\nNo frontmatter status here.\n',
|
|
);
|
|
writeVerificationMd(dir, '03-VERIFICATION.md', 'passed');
|
|
|
|
const result = readVerificationStatus(dir);
|
|
assert.equal(
|
|
result.status,
|
|
'passed',
|
|
'the phase must report the canonical report\'s status, not missing',
|
|
);
|
|
} finally {
|
|
cleanup(baseDir);
|
|
}
|
|
});
|
|
|
|
test('behavioral (findStaleVerificationSummary): staleness is checked against THIS phase\'s own report, not a cross-phase stray', () => {
|
|
// A stray cross-phase file ("99-VERIFICATION.md") is alphabetically AFTER
|
|
// this phase's own "03-VERIFICATION.md", so this also demonstrates the
|
|
// pin is not merely riding on alphabetical luck.
|
|
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3492-stale-parent-'));
|
|
const dir = path.join(baseDir, '03-stale-pin-test');
|
|
fs.mkdirSync(dir);
|
|
try {
|
|
writeVerificationMd(dir, '03-VERIFICATION.md', 'passed');
|
|
writeVerificationMd(dir, '99-VERIFICATION.md', 'passed');
|
|
setMtime(path.join(dir, '03-VERIFICATION.md'), '2020-01-01T00:00:00Z');
|
|
setMtime(path.join(dir, '99-VERIFICATION.md'), '2020-01-01T00:00:00Z');
|
|
|
|
// Root-style summary placement (mirrors the #2348 fixtures above) —
|
|
// scanPhasePlans's nested-layout matcher requires `SUMMARY-<NN>...md`
|
|
// inside a `plans/` subdir; a root-named `03-01-SUMMARY.md` dropped into
|
|
// `plans/` matches neither isRootSummaryFile (wrong directory) nor
|
|
// isNestedSummaryFile (wrong filename shape), so summaryFiles reads
|
|
// empty and the phase is never stale — not what this test means to
|
|
// exercise.
|
|
const summaryPath = path.join(dir, '03-01-SUMMARY.md');
|
|
fs.writeFileSync(summaryPath, '# summary\n');
|
|
setMtime(summaryPath, '2021-01-01T00:00:00Z');
|
|
|
|
// Force the mtime path (no git clock) by injecting an empty resolver —
|
|
// mirrors the existing #2348 test pattern elsewhere in this file.
|
|
const result = findStaleVerificationSummary(dir, fs, () => new Map());
|
|
assert.equal(result.determined, true);
|
|
assert.equal(result.stale, true, 'the phase\'s own 03-VERIFICATION.md is older than its summary');
|
|
assert.equal(
|
|
result.verificationFile,
|
|
'03-VERIFICATION.md',
|
|
'staleness must be computed against the phase\'s own report, not the cross-phase 99-VERIFICATION.md stray',
|
|
);
|
|
} finally {
|
|
cleanup(baseDir);
|
|
}
|
|
});
|
|
|
|
});
|
|
|
|
// ─── #3473 F2: resolveVerificationFile allowBare option ──────────────────────
|
|
//
|
|
// commands.cts (determinePhaseStatus) and two verification_path projectors in
|
|
// init.cts each hand-rolled a fourth variant of this same selection: they
|
|
// additionally accept a BARE `VERIFICATION.md`, which this module's own two
|
|
// callers (findStaleVerificationSummary, readVerificationStatus) never have.
|
|
// `allowBare` threads that one behavioral difference through the single
|
|
// resolver instead of leaving a fourth hand-rolled implementation behind
|
|
// (#3473 F2). A bare match is ranked BELOW any dashed candidate — canonical
|
|
// or not — because a dashed file names its phase and a bare one does not.
|
|
describe('#3473 F2: resolveVerificationFile allowBare option', () => {
|
|
|
|
test('allowBare defaults to false — a bare-only list returns null without the option', () => {
|
|
assert.equal(resolveVerificationFile(['VERIFICATION.md']), null);
|
|
});
|
|
|
|
test('allowBare:true, bare-only candidate → bare file returned', () => {
|
|
assert.equal(
|
|
resolveVerificationFile(['VERIFICATION.md'], { allowBare: true }),
|
|
'VERIFICATION.md',
|
|
);
|
|
});
|
|
|
|
test('allowBare:true, bare + non-canonical dashed → the dashed fallback wins', () => {
|
|
assert.equal(
|
|
resolveVerificationFile(
|
|
['VERIFICATION.md', '01-review-VERIFICATION.md'],
|
|
{ allowBare: true },
|
|
),
|
|
'01-review-VERIFICATION.md',
|
|
'a dashed non-canonical file names its phase and must win over a bare match',
|
|
);
|
|
});
|
|
|
|
test('allowBare:true, bare + canonical → the canonical file wins', () => {
|
|
assert.equal(
|
|
resolveVerificationFile(
|
|
['VERIFICATION.md', '03-VERIFICATION.md'],
|
|
{ allowBare: true },
|
|
),
|
|
'03-VERIFICATION.md',
|
|
);
|
|
});
|
|
|
|
// #3511: allowBare must still fall through to the bare match when the ONLY
|
|
// dashed candidate is excluded by phaseDirName scoping (a cross-phase
|
|
// stray) — the fallback tier finding nothing phase-owned is the same
|
|
// "no dashed candidate at all" case allowBare was always reached from.
|
|
test('#3511: allowBare:true, bare + a cross-phase dashed stray scoped out by phaseDirName → the bare file wins', () => {
|
|
assert.equal(
|
|
resolveVerificationFile(
|
|
['VERIFICATION.md', '04-VERIFICATION.md'],
|
|
{ allowBare: true, phaseDirName: '03-foo' },
|
|
),
|
|
'VERIFICATION.md',
|
|
'04-VERIFICATION.md belongs to a different phase and is excluded, so bare VERIFICATION.md is the only remaining candidate',
|
|
);
|
|
});
|
|
|
|
});
|
|
|
|
// ─── #3518: resolveUatFile — phase-pinned, deterministic *-UAT.md pick ───────
|
|
//
|
|
// Both uat_path projectors in src/init.cts picked the phase's UAT artifact
|
|
// with a bare `.find((f) => f.endsWith('-UAT.md') || f === 'UAT.md')` over an
|
|
// unsorted readdir listing: no phase-membership check, no ordering. A stray
|
|
// cross-phase 04-UAT.md in phase 03's directory could become phase 03's
|
|
// uat_path, and WHICH file won was filesystem-dependent (creation order on
|
|
// APFS, hash order on ext4/XFS) — two machines on the same commit could emit
|
|
// different uat_path values for the same phase (#3518).
|
|
//
|
|
// resolveUatFile is the UAT counterpart of resolveVerificationFile, sharing
|
|
// the identical selection rule via the resolvePhaseArtifactFile core: the
|
|
// phase's own <token>-UAT.md always wins; otherwise alphabetically-first
|
|
// dashed candidate (deterministic on every filesystem); a bare UAT.md only
|
|
// when allowBare is set and no dashed candidate exists at all.
|
|
//
|
|
// These unit tests are the RELIABLE ANCHORS for the rule (a real phaseToken
|
|
// string, no readdir order involved). The end-to-end red/green repro for the
|
|
// two init.cts projector call sites lives in tests/init.test.cjs (#3518).
|
|
describe('#3518: resolveUatFile — phase-pinned *-UAT.md resolution', () => {
|
|
|
|
test('#3518 regression: a stray cross-phase -UAT.md does not outrank this phase\'s own UAT file', () => {
|
|
assert.equal(
|
|
resolveUatFile(
|
|
['04-UAT.md', '03-UAT.md'],
|
|
{ phaseToken: '03' },
|
|
),
|
|
'03-UAT.md',
|
|
'this phase (token "03") owns 03-UAT.md; the stray 04-UAT.md belongs to a different phase',
|
|
);
|
|
});
|
|
|
|
test('order-independence: same candidates reversed → same answer', () => {
|
|
assert.equal(
|
|
resolveUatFile(
|
|
['03-UAT.md', '04-UAT.md'],
|
|
{ phaseToken: '03' },
|
|
),
|
|
'03-UAT.md',
|
|
'input order must not change which file is selected',
|
|
);
|
|
});
|
|
|
|
test('fallback: only a stray cross-phase file present → still returned, never null', () => {
|
|
// Load-bearing: a phase whose only UAT artifact is not its own
|
|
// canonically-named file must keep resolving to SOMETHING, not to null —
|
|
// deterministically (alphabetically-first) rather than by readdir order.
|
|
assert.equal(
|
|
resolveUatFile(['04-UAT.md', '02-UAT.md'], { phaseToken: '03' }),
|
|
'02-UAT.md',
|
|
'"02-UAT.md" sorts before "04-UAT.md" — deterministic even when the phase\'s own file is absent',
|
|
);
|
|
});
|
|
|
|
test('fallback determinism: several candidates, no phase token given → alphabetically first', () => {
|
|
assert.equal(resolveUatFile(['02-UAT.md', '01-a-UAT.md']), '01-a-UAT.md');
|
|
});
|
|
|
|
test('allowBare defaults to false — a bare-only list returns null without the option', () => {
|
|
assert.equal(resolveUatFile(['UAT.md']), null);
|
|
});
|
|
|
|
test('allowBare:true, bare + dashed → the dashed candidate wins', () => {
|
|
assert.equal(
|
|
resolveUatFile(['UAT.md', '03-UAT.md'], { allowBare: true, phaseToken: '03' }),
|
|
'03-UAT.md',
|
|
'a dashed file names its phase and must win over a bare match',
|
|
);
|
|
});
|
|
|
|
test('allowBare:true, bare-only candidate → bare file returned', () => {
|
|
assert.equal(resolveUatFile(['UAT.md'], { allowBare: true }), 'UAT.md');
|
|
});
|
|
|
|
test('no matches → null', () => {
|
|
assert.equal(resolveUatFile(['03-PLAN.md', '03-SUMMARY.md'], { phaseToken: '03' }), null);
|
|
});
|
|
});
|
|
|
|
// ─── #3518: call-site guard — no hand-rolled *-UAT.md single-pick survives ────
|
|
//
|
|
// The "Partial Fix Across Call Sites" regression class: a future contributor
|
|
// adding a NEW uat_path-style projection (or reverting one of the two fixed
|
|
// init.cts sites) would hand-roll `.find((f) => f.endsWith('-UAT.md') ||
|
|
// f === 'UAT.md')` again — reintroducing the readdir-order,
|
|
// no-phase-check pick #3518 closed. This scans src/ for that literal shape
|
|
// and fails on any site outside src/verification.cts, whose
|
|
// resolvePhaseArtifactFile core is the single owner of the pattern.
|
|
// (src/commands.cts's scaffold WRITER builds `${padded}-UAT.md` directly —
|
|
// a canonical-name construction, not a discovery pick — and does not match.)
|
|
describe('#3518: call-site guard — every *-UAT.md single-pick routes through resolveUatFile', () => {
|
|
|
|
test('no hand-rolled -UAT.md discovery pick exists outside src/verification.cts', () => {
|
|
const srcDir = path.join(__dirname, '..', 'src');
|
|
const owner = path.join(srcDir, 'verification.cts');
|
|
// The two shapes the pre-#3518 bug appeared as: an endsWith('-UAT.md')
|
|
// predicate, or a bare `=== 'UAT.md'` equality, anywhere in src/.
|
|
const HAND_ROLLED_RE = /endsWith\(['"`]-UAT\.md['"`]\)|===\s*['"`]UAT\.md['"`]/;
|
|
const offenders = [];
|
|
for (const file of fs.readdirSync(srcDir)) {
|
|
if (!file.endsWith('.cts')) continue;
|
|
const fullPath = path.join(srcDir, file);
|
|
if (fullPath === owner) continue;
|
|
// CRLF-tolerant split (local/no-crlf-fragile-split): Windows
|
|
// git-autocrlf checkouts yield \r\n line endings.
|
|
const lines = fs.readFileSync(fullPath, 'utf-8').split(/\r?\n/);
|
|
lines.forEach((line, i) => {
|
|
if (HAND_ROLLED_RE.test(line)) offenders.push(`src/${file}:${i + 1}: ${line.trim()}`);
|
|
});
|
|
}
|
|
assert.deepStrictEqual(
|
|
offenders,
|
|
[],
|
|
'hand-rolled *-UAT.md single-pick(s) — route through resolveUatFile '
|
|
+ '(src/verification.cts, issue #3518):\n'
|
|
+ offenders.join('\n'),
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── #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');
|
|
const contentLines = content.split(/\r?\n/);
|
|
const fences = scanFencedBlocks(contentLines)
|
|
.filter((b) => b.closeLineIdx !== -1 && (b.infoString || '').trim() === 'bash')
|
|
.map((b) => contentLines.slice(b.openLineIdx, b.closeLineIdx + 1).join('\n'));
|
|
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',
|
|
);
|
|
});
|
|
});
|
|
});
|
|
}
|