* feat(#3987): guard slug re-derivation, and record why the swallow shape cannot be guarded Epic #3473's Decision 1 requires the wrong call site be UNREPRESENTABLE. #3984 measured that two of the nine §8 rules had no guard at all and recorded both as "Shipped - test-covered". This closes one of them, proves the other cannot be closed the same way, and corrects two false claims I merged yesterday. 1. §8.3 - scripts/lint-slug-derivation-drift.cjs. generateSlugInternal (src/core-utils.cts) is the canonical owner; #3883 removed 11 inline copies. Nothing prevented a twelfth: no slug guard existed in scripts/ or eslint-rules/. The detector is STATEMENT-scoped and matches the shape the real copies took - one statement carrying BOTH .replace(<negated class>, '-') and .replace(/^-+|-+$/, ''). Statement scoping is what buys the precision: the loose LINE-level form yields 18 hits with 7 unrelated, a material false-positive rate. Measured on the tree: 5 flags, 2 TRUE, 3 SANCTIONED, 0 FALSE. The three sanctioned sites are allowlisted with a reason each, following lint-phase-enumeration-drift's form rather than a bare denylist. The owner itself is listed explicitly even though it escapes by construction - an implicit escape is a latent bug, and the next person to touch line 192 would not know the guard depended on it. 2. Both TRUE positives were live defects, not style. scripts/qa-smell-ratchet.cjs reproduced the canonical formula including the 60-cap but trimmed BEFORE truncating - the #2849 bug - and never transliterated. The divergence is total, not cosmetic: canonical "privet-mir-privet-mir-privet-mir-privet-mir-privet-mir-prive" inline "tail" Cyrillic collapsed to nothing and only the ASCII remainder survived, so the ratchet was keying on wrong identifiers for any non-ASCII input. tests/planning-inspect.test.cjs carried a helper whose comment claimed parity with getPhaseDirFromPhaseId. That function now transliterates; the helper did not, so the test asserted against a stale formula while looking correct. Both now route through the seam. 3. §8.5 - measured, and deliberately NOT shipped. A candidate detector (swallowing catch + errno-retry-set test in the same function) gives 26 flags across 11 functions: 0 TRUE, 26 FALSE. Every one is best-effort unlink/rm/close cleanup, lost-rename-race backoff, or a deliberate null fallback. The file-scoped variant is worse at 71. Worse than the noise: the only known true instance was removed by #3885, so there is NO POSITIVE CONTROL - the guard cannot be shown capable of failing, which this repo requires of every drift guard. Shipping it would add a guard nobody can trust and nobody can test. The ADR now records the measurement and the reason, keeps §8.5 at "Shipped - test-covered", and points at the #1884 regression test as what actually enforces it. An honest "not detectable at acceptable precision" beats a guard that only ever passes. 4. Two claims I merged into the ADR yesterday were wrong. §8.9 said 17 of 19 subsumed children have a test citing their issue number, and that #3364 and #3812 have none. Both halves are false, and the claim came from a NUMBER-GREP - inside an amendment whose own subject is that a text match is not a fact. #3364 IS cited: tests/runtime-marker-resolution.test.cjs:107, T3 installMarkerResolvesWhenEnvAndConfigAbsent_3897 (#3364), asserting at :115-119. #3812 IS covered: tests/gen-state-md-docs.test.cjs:374, asserting at :382. Corrected to 19 of 19. #3812 does carry a real finding, though a different one: it is PARTIALLY DELIVERED on a CLOSED issue. The shipped fix declares cardinality for frontmatter keys, but #3812's stated acceptance was about the ## Current Position BODY section, and docs/reference/state-md.md:196-208 still has no normative single-valued/overwrite sentence and no pointer to ## Performance Metrics for history. Recorded in the ADR and left for #3812 to re-open - fixing it here would bury a scope question inside an unrelated PR. Note on B6: this ADDS a guard, and B6 said the net count must fall. #3951 already amended that clause - a guard ledger is a claim about COVERAGE, not count - which is what makes adding this one honest rather than contradictory. Verified: the guard flags 0 on the fixed tree, and PROVES IT CAN FAIL - a fresh inline copy planted in src/ makes it exit 1 naming the exact statement. All three sanctioned sites were confirmed exempt BY the allowlist, not by accident of the pattern, by re-attributing each to a non-exempt path and watching it flag. build:lib, lint and lint:ci all exit 0. Refs #3987 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#3987): add the changeset fragment Doc-only, so it carries forward from the verified sha rather than costing a second matrix run. Refs #3987 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3987): §8.5 IS guardable — I was wrong, and the guard found a live defect Two orthogonal reviews. The correctness review overturned my central judgment, and it was right. 1. I concluded §8.5 was "not detectable at acceptable precision" and recorded that in the ADR. False. My evidence was 26 flags / 0 TRUE / 26 FALSE. The reviewer pointed out what I had not: all 26 false positives are CLEANUP verbs - rmSync 54, unlinkSync 43, closeSync 17, chmodSync 12 - and the obvious narrower predicate was never tried. A swallowed cleanup is legitimate best-effort. A swallowed CREATION is a precondition silently lost, which is exactly the #1884 shape. Measured properly, in three stages: swallowing catch 911 + try-block calls a CREATION verb 24 + enclosing function references a *_ERRNOS set 0 0 flags, 0 false positives. The `*_ERRNOS` naming key is empirically total - all 10 retry/tolerate sets in src/ follow it. My second claim was worse. I wrote that no positive control exists because #3885 removed the only true instance, so the guard "cannot be shown capable of failing". That is self-refuting: this very PR's slug guard proves-it-can-fail on a synthetic tree, and the pre-#3885 blob is available as exactly such a fixture. It is now the control, and it works in both directions - the rule flags 0c43d853e^:src/planning-workspace.cts at line 210, the line the fix commit's own message cites, and reports zero on the post-fix code. I stopped at the first negative result on the option that meant less work. Shipped as eslint-rules/no-swallowed-precondition.cjs, wired into the existing src/**/*.cts ESLint block rather than a scripts/lint-*-drift.cjs: no script in scripts/ requires typescript/espree/acorn, and scripts/ ships to consumers, so a .cts-parsing standalone guard would add a devDep at consumer runtime. The ESLint block already parses .cts for free. 2. The guard immediately found a live defect of the same class. src/capability-lock.cts swallowed a mkdirSync on the lock directory, then acquireLock classified the follow-on failure as `code !== 'EEXIST' → return null`. A real EACCES/EROFS makes openSync(lockPath,'wx') fail ENOENT, which is not EEXIST - so a fatal filesystem error was laundered into "lock unavailable". Same defect as #1884, different laundering target. Fixed the way #3885 fixed #1884: the creation failure propagates. Regression test proven fail-first by hand - with the fix stashed, EACCES was laundered to null; restored, it throws. The strict rule does NOT catch this shape (its errno classification is an inline literal, not a named set). The rule is deliberately left strict: the broadened form had 2 false positives - capability-lock.cts:408, the deliberate EEXIST steal protocol, and commonjs-marker.cts:131, which returns a distinct documented outcome. The gap is noted in code rather than papered over with a noisy predicate. 3. The security review found the slug guard's exemption FAILED OPEN. currentFunction was never reset, and only a column-0 `function` declaration updated it, so exemption bled from an allowlisted declaration to the next one. generateSlugInternal exempted 50 lines for an 11-line function. A re-derivation planted anywhere in that window was silently exempt - the same fail-open shape that produced a blocker in #3897, and an allowlist is a SUBTRACTION so a mismatch fails open by construction. Extent is now tracked by real brace depth, and a test plants a violation after each allowlisted function's real closing brace and asserts it IS flagged. 4. Also from the security review: the guard was a CI-DoS and narrower than I claimed. Its unbounded [^\]]* was re-scanned from every `.replace(/[^` start: 54.3s on a 1.28MB line. It imported MAX_REGEX_LITERAL_LEN and never called readRegexLiteralAt - the bounded tokenizer that exists for exactly this. Now routed through it with a 2MB file cap: ~200ms. 15 of 25 genuine re-derivations evaded. Widened to catch replaceAll, {1,}, \s*-wrapped classes, escaped ], literal new RegExp(...), five trim spellings, .split().join(), and multi-line .replace( args - still 0 false positives. Two forms still evade and are documented as deliberate gaps with negative tests: the two-statement/temp-var form and new RegExp built from a variable. Both need data flow, and guessing at it is how a guard becomes noisy. Also fixed: // inside a string truncated the line, a ; inside the collapse regex split the statement (a one-character bypass), and SCAN_EXT omitted .mjs/.tsx/.jsx. 5. A regression I introduced, caught by the same review. qa-smell-ratchet.cjs top-level-required a build output that is not git-tracked, so the script hard-failed MODULE_NOT_FOUND before build:lib - including for --help, which previously had no build dependency. The require is now lazy at the point of use. 6. Four of my own tests were vacuous or weak. T9's input yielded an identical string under the buggy formula, so it passed on the implementation it was meant to catch. T12 compared maxLen null vs 60 on an 18-char name, where they agree trivially. T9-T12 all asserted generateSlugInternal directly, so they would pass unchanged if both call-site fixes were reverted. And prove-it-can-fail was scoped to scanRepo, never the CLI - dropping main()'s exit-code line would have kept every row green. All rewritten with discriminating inputs, per-call-site rows that red when the fix is reverted, 59/60/61 boundaries, an entirely-non-alphanumeric row, and a CLI row asserting the real subprocess exit code and both sanitizeForReport sites. Verified: both guards flag 0 on the tree and both prove they can fail. The swallow rule's control is confirmed in both directions - pre-#1884 shape flagged, post-#3885 shape clean. build:lib, lint and lint:ci all exit 0. Refs #3987 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(#3987): record that §8.5 IS guardable, and correct a correction that made a ledger worse Three ADR corrections, two of them to text this branch wrote hours ago. §8.5 advances to Enforced. Its previous entry said the rule was not detectable at acceptable precision. That was wrong twice: the 26 false positives were uniformly CLEANUP verbs, which is a reason to narrow the predicate rather than abandon it, and the claim that no positive control exists was self-refuting - the pre-#3885 blob is available as a fixture and this repo's own guards prove-it-can- fail on synthetic trees. Narrowed to creation verbs plus a *_ERRNOS reference: 911 -> 24 -> 0 flags, 0 false positives, control confirmed in both directions. The entry keeps the wrong reasoning visible, because a high false-positive count being evidence the predicate is wrong - not evidence the rule is unguardable - is the transferable part, and the first negative result is most seductive when it is also the answer that means less work. §8.9's correction is itself corrected. The original 17-of-19 claim was CORRECT for the predicate it stated; this branch silently swapped cited -> covered and declared 19 of 19. #3812 appears in zero test files. Changing what a word means to make a ledger read better is a worse failure than the miscount it claimed to repair. Both predicates are now reported separately - 18 of 19 cited, 19 of 19 covered - because §8.9 asks for a test NAMING each child, so 18 is the number that answers it. #3812 is also re-opened for real, rather than the first draft's promise that it could be. §8.3 stays Shipped - test-covered rather than advancing. The slug guard catches the copy-paste class and a dozen variants, but two forms still evade by decision (temp-var split, new RegExp from a variable) because both need data flow. Naming them keeps the status honest: the wrong call site is much harder to write, not unrepresentable. Closes #3987 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#3987): backfill changeset pr number Refs #3987 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3987): replace my own wall-clock assertion, and close the guard that let me write it CI went red on ubuntu shard 2/3. The failing test was mine, and the failure was the test, not the code. a 1.28MB line ... scans in well under a second (was 54.3s pre-fix) 7368ms It asserted ELAPSED TIME. ~200ms locally, 7.4s on a shared CI runner. The bound introduced for the MAJOR-2 DoS fix works - 7.4s against a 54.3s pre-fix baseline is the fix doing its job - but an absolute wall-clock threshold on shared hardware is a race, not an assertion. CLAUDE.md says so directly: "Clock Seams: Do not assert on wall-clock time." I wrote the anti-pattern the project bans, in a PR about guards. Raising the threshold would only move the flake. The row now asserts a DETERMINISTIC bound instead: an instrumentation seam on drift-scan.cjs counts readRegexLiteralAt calls and characters examined, and the test asserts charsExamined stays under an absolute ceiling. Measured on the same 1.28MB fixture: 120,000 calls, 48,000,000 chars - two orders under the ceiling. The pathological fixture is kept; only the thing being asserted changed. Proven to still discriminate: with MAX_REGEX_LITERAL_LEN raised to simulate the unbounded pre-fix behavior, the same fixture does not complete in 120 seconds, versus ~0.3s bounded. It is a real regression test, not a tautology. Then the second half, which is the same defect class as the rest of this PR. eslint-rules/no-elapsed-assertion.cjs matched only the EXACT identifiers ^(elapsed|duration|took|ms)$. I used `elapsedMs`. It evaded the rule entirely. tookMs, durationMs, elapsedTime and msElapsed evade the same way. A guard that cannot see the violation it exists to catch is exactly what this PR is about - it just happened to be an existing rule rather than one of the two I came here for, and it was found because I committed the violation it should have blocked. Widened to /^(?:elapsed|duration|took|ms)(?:[A-Z]\w*)?$/ plus a narrow start/endMs delta pair. Deliberately NOT a blanket *Ms suffix: a first draft did that and produced 2 false positives on `timeoutMs` in plan-phase-stall-detection, which is a configured timeout and not a measurement. Verified negative on params, items, forms, terms, dirnames, timeoutMs, cacheTtlMs and staleAfterMs. Measured over the five files carrying camelCase timing identifiers: 0 true positives beyond my own, so nothing else needed rewriting. The rule's own test file gains a row asserting `elapsedMs` flags, proven to fail against the pre-widening rule - the same prove-it-can-fail standard both new guards in this PR are held to. Refs #3987 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3987): a comment I added leaked a Claude reference into every runtime install The runner went red with 4 failures in tests/install.test.cjs: Leaking: .hermes/scripts/lib/drift-scan.cjs Leaking: .qwen/scripts/lib/drift-scan.cjs The instrumentation seam added for the deterministic bound carried a comment naming CLAUDE.md as the source of the no-wall-clock-assertions rule. scripts/ SHIPS to consumers, so that comment was installed verbatim into hermes and qwen trees, and the install suite scans for exactly this - a Claude-specific reference reaching a non-Claude runtime. The rule is real and worth citing; the filename is not portable. The comment now says "this repo's test rules" and states the rule inline, which is what a reader of an installed tree actually needs anyway. Worth noting what caught it: not lint, and not the two guards this PR adds - the install suite's full-tree scan, which exists precisely because a shipped file is read by runtimes that have never heard of CLAUDE.md. Same lesson as the rest of this PR from the other direction: the check that matters is the one that can see the surface where the defect actually lands. Refs #3987 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3987): a test fixture swallowed 46 git exit codes and produced a silent false negative CI red on ubuntu shard 3/3: tests/health-validation.test.cjs:2029 expected exactly one W024, got [{"code":"W006", ...}] Not caused by this branch, and the evidence is decisive rather than a hunch: the SIBLING test at :2039 builds the IDENTICAL fixture with the identical commitsAhead and asserts the same thing, and it PASSED in the same process, same file, same run. Same input, both outcomes - which rules out logic, ordering, sharding and environment, and leaves a per-invocation nondeterministic failure inside one fixture build. The mechanism is an unchecked exit code, 46 times over. The W024 fixture performs ~46 runGit spawns and never checks a single one. runGit returns failures as DATA and never throws, so one silently-failed `git commit` yields 19 commits instead of 20, or a silently-empty `git rev-parse HEAD` yields a blank state_head. Either drops readStateHeadFreshness below the advisory threshold, W024 never fires, and only W006 remains. Reproduced exactly: 20 commits -> ["W006","W024"]; 19 -> ["W006"]; blank state_head -> ["W006"] - byte-identical to the CI assertion dump. The arithmetic is what hid it. At threshold-1 and threshold+1 a lost commit still produces the asserted answer; only the exactly-at-threshold cases sit one commit from a false negative. Two of the seven tests are in that position, and CI hit one. That is why it had never been seen before, and why it surfaced now: this branch adds three test files, which reshuffles the cost-weighted shard partition and moved this file into a chunk where the latent flake fired. My files were checked as suspects first and cleared: all fixtures mkdtemp-unique, no process.chdir, no .planning/ writes, no git spawns, and node --test gives per-file process isolation regardless. Fixed at the cause, not the symptom. A mustGit wrapper throws on a non-zero exit with the command, exit code and stderr, and all nine call sites route through it. The fixture now asserts its OWN preconditions before the assertion under test runs - the seed head is non-empty, and `git rev-list --count <seed>..HEAD` equals the requested commitsAhead - so a fixture that did not build what it claims fails loudly as a FIXTURE ERROR naming got-versus-asked, instead of quietly handing a weaker input to the assertion. Proven: dropping one commit now raises FIXTURE ERROR: requested commitsAhead=19 but git rev-list --count reports 18 where it previously produced a silent ["W006"] pass-for-the-wrong-reason. 64/64 tests in that block pass unperturbed. Deliberately NOT done: no threshold change, no retry, no loosened assertion, no skip. The assertion was correct; the input was silently wrong. Worth naming, because it is the same shape from the other side: this PR ships eslint-rules/no-swallowed-precondition.cjs, whose entire subject is a swallowed precondition failure being laundered into a plausible downstream outcome. This fixture is that defect in test code - the swallowed git failure was laundered into a legitimate-looking "W024 did not fire". The rule does not cover test fixtures, so the connection is noted at the fix site rather than enforced. Refs #3987 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3987): two tests wrote to committed files; the shard packing decided when that mattered CI red on windows-latest shard 1/3 only: "gen-exit-code-registry: CLI" > "a --write run redirected to a tmpdir leaves every committed artifact untouched" AssertionError: hooks artifact must be untouched The Linux runner passed the same sha at 40425/40425. It is Linux-only, so a Windows-scheduling defect is structurally invisible to it. Root cause, established by measurement rather than inference. tests/cli-exit.test.cjs appended a corruption marker to the REAL COMMITTED hooks/lib/exit-code-registry.js, held it corrupted across a full subprocess, and restored it in a finally. tests/exit-code-registry.test.cjs reads that same real file before and after its own subprocess and asserts byte equality. If it samples while the other test holds the file corrupted, it fails. The landmine is pre-existing, from2ea5efc15(#3911). What this branch changed is WHEN the two run together. scripts/run-tests.cjs shards by cost-weighted LPT over the sorted unit list, so adding three test files repacks the bins: merge-basec3e667df3(838 files): cli-exit -> shard 1, exit-code-registry -> shard 3 HEAD 03b342601 (841 files): BOTH in shard 1, same argv chunk, one node --test process, concurrent Co-location is necessary but not sufficient - Linux shard 1/3 also had both and passed. Windows loses because TEST_CONCURRENCY defaults to 2 there against 4 elsewhere, spawn cost is ~10x, and the sibling corruptor holds one of only two slots through a ~90s tsc compile. That turns a sub-second overlap into seconds. Not a path-separator or case-sensitivity issue, and not CRLF - .gitattributes pins * text=auto eol=lf. Redirection was not at fault either: ensureScriptsOut derives all five --out flags correctly and gen-exit-code-registry.cjs honours them with no __dirname escape. Fixed at the cause: no test writes to a committed file any more. Both corruptors now copy to a mkdtempSync tmpdir, corrupt the COPY, and point the generator at it. Repinning or reordering the shards would have turned CI green while leaving the landmine armed for the next reshuffle. That required closing an inconsistency between two sibling generators. gen-exit-code-registry.cjs already accepts --out/--scripts-out/--hooks-out/--dts-out/--sh-out and honours them under --check; gen-hooks-cli-exit.cjs hardcoded OUTPUT_PATH and had no flag surface at all, so its corruptor could not be redirected anywhere. It now takes --out in the same style, honoured by both --write and --check, and is a no-op when absent - verified: a bare --check on the default path still exits 0. ensureScriptsOut moved to tests/helpers/exit-code-artifact-flags.cjs and both test files import it. Hand-rolling a second copy of the flag derivation would have been a re-derivation of exactly the kind this PR ships a guard against. Verified: both tests still detect corruption (proven by defeating the check and watching them red, with a positive control showing an uncorrupted copy exits 0); SHA-256 of hooks/lib/exit-code-registry.js and hooks/lib/cli-exit.js identical before and after running both rewritten bodies, and git reports nothing under hooks/ modified - that is the property that was violated. A repo-wide search for the corrupt-then-restore-in-finally shape against hooks/ found no other instances. One detail worth recording: the tmpdir test keeps --declaration pointed at the real committed declaration rather than copying it, because the generated banner embeds path.relative(REPO_ROOT, declarationPath) - copying it would produce a false drift unrelated to the injected corruption. The declaration is read-only on that path and never written. Refs #3987 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2101 lines
90 KiB
JavaScript
2101 lines
90 KiB
JavaScript
/**
|
||
* GSD Tools Tests - Health Validation
|
||
*
|
||
* Tests for fix/health-validation-1473c:
|
||
* - W011: STATE/ROADMAP cross-validation (phase divergence detection)
|
||
* - W012: branching_strategy validation
|
||
* - W013: context_window validation
|
||
* - W014: phase_branch_template placeholder validation
|
||
* - W015: milestone_branch_template placeholder validation
|
||
* - stateReplaceFieldWithFallback field-miss warning
|
||
* - Boundary conditions and edge cases
|
||
*/
|
||
|
||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||
|
||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||
|
||
function writeMinimalRoadmap(tmpDir, phases = ['1']) {
|
||
const lines = phases.map(n => `### Phase ${n}: Phase ${n} Description`).join('\n');
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||
`# Roadmap\n\n${lines}\n`
|
||
);
|
||
}
|
||
|
||
function writeMinimalStateMd(tmpDir, content) {
|
||
const defaultContent = content || `# Session State\n\n## Current Position\n\nPhase: 1\n`;
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
defaultContent
|
||
);
|
||
}
|
||
|
||
function writeMinimalProjectMd(tmpDir) {
|
||
const sections = ['## What This Is', '## Core Value', '## Requirements'];
|
||
const content = sections.map(s => `${s}\n\nContent here.\n`).join('\n');
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'PROJECT.md'),
|
||
`# Project\n\n${content}`
|
||
);
|
||
}
|
||
|
||
function writeValidConfigJson(tmpDir, overrides = {}) {
|
||
const base = { model_profile: 'balanced', commit_docs: true };
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'config.json'),
|
||
JSON.stringify({ ...base, ...overrides }, null, 2)
|
||
);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 1. W011: STATE/ROADMAP cross-validation
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('W011: STATE/ROADMAP cross-validation', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('STATE says current phase but ROADMAP shows it as complete -> warning', () => {
|
||
writeMinimalProjectMd(tmpDir);
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||
`# Roadmap\n\n- [x] Phase 3: Database Layer\n\n### Phase 3: Database Layer\n**Goal:** DB setup\n`
|
||
);
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
`# Session State\n\n**Current Phase:** 03\n**Current Phase Name:** Database Layer\n**Status:** In progress\n`
|
||
);
|
||
writeValidConfigJson(tmpDir);
|
||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-database-layer'), { recursive: true });
|
||
|
||
const result = runGsdTools('validate health', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.ok(
|
||
output.warnings.some(w => w.code === 'W011'),
|
||
`Expected W011 in warnings: ${JSON.stringify(output.warnings)}`
|
||
);
|
||
});
|
||
|
||
test('STATE and ROADMAP agree (phase not checked off) -> no W011 warning', () => {
|
||
writeMinimalProjectMd(tmpDir);
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||
`# Roadmap\n\n- [ ] Phase 2: API Layer\n\n### Phase 2: API Layer\n**Goal:** Build API\n`
|
||
);
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
`# Session State\n\n**Current Phase:** 2\n**Status:** In progress\n`
|
||
);
|
||
writeValidConfigJson(tmpDir);
|
||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-api-layer'), { recursive: true });
|
||
|
||
const result = runGsdTools('validate health', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.ok(
|
||
!output.warnings.some(w => w.code === 'W011'),
|
||
`Should not have W011: ${JSON.stringify(output.warnings)}`
|
||
);
|
||
});
|
||
|
||
// ─── #3280 — the STATE.md format `gsd-tools state update` /
|
||
// `state begin-phase` actually persist: YAML frontmatter `current_phase` +
|
||
// `status` (syncStateFrontmatter), not the legacy bold-prose fields. New
|
||
// fixtures added alongside the legacy cases above (which stay, since real
|
||
// user repos still carry the prose format).
|
||
|
||
test('#3280: frontmatter current_phase + ROADMAP [x] -> W011 warning', () => {
|
||
writeMinimalProjectMd(tmpDir);
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||
`# Roadmap\n\n- [x] Phase 3: Database Layer\n\n### Phase 3: Database Layer\n**Goal:** DB setup\n`
|
||
);
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
[
|
||
'---',
|
||
"gsd_state_version: '1.0'",
|
||
'milestone: v1.0',
|
||
'current_phase: 3',
|
||
'current_phase_name: Database Layer',
|
||
'status: executing',
|
||
'---',
|
||
'',
|
||
'# Session State',
|
||
'',
|
||
'## Current Position',
|
||
'',
|
||
'Phase: 3 of 4 (Database Layer)',
|
||
'',
|
||
].join('\n')
|
||
);
|
||
writeValidConfigJson(tmpDir);
|
||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-database-layer'), { recursive: true });
|
||
|
||
const result = runGsdTools('validate health', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.ok(
|
||
output.warnings.some(w => w.code === 'W011'),
|
||
`Expected W011 in warnings: ${JSON.stringify(output.warnings)}`
|
||
);
|
||
});
|
||
|
||
test('#3280: frontmatter current_phase + status completed (writer vocabulary) -> no W011 warning', () => {
|
||
writeMinimalProjectMd(tmpDir);
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||
`# Roadmap\n\n- [x] Phase 3: Database Layer\n\n### Phase 3: Database Layer\n**Goal:** DB setup\n`
|
||
);
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
[
|
||
'---',
|
||
"gsd_state_version: '1.0'",
|
||
'current_phase: 3',
|
||
'current_phase_name: Database Layer',
|
||
'status: completed',
|
||
'---',
|
||
'',
|
||
'# Session State',
|
||
'',
|
||
'## Current Position',
|
||
'',
|
||
'Phase: 3 of 4 (Database Layer)',
|
||
'',
|
||
].join('\n')
|
||
);
|
||
writeValidConfigJson(tmpDir);
|
||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-database-layer'), { recursive: true });
|
||
|
||
const result = runGsdTools('validate health', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.ok(
|
||
!output.warnings.some(w => w.code === 'W011'),
|
||
`Should not have W011 for a completed phase in the writer's own status vocabulary: ${JSON.stringify(output.warnings)}`
|
||
);
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 2. W012-W015: Config field validation
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('config field validation', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('W012: invalid branching_strategy triggers warning', () => {
|
||
writeMinimalProjectMd(tmpDir);
|
||
writeMinimalRoadmap(tmpDir, ['1']);
|
||
writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n');
|
||
writeValidConfigJson(tmpDir, { branching_strategy: 'banana' });
|
||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
|
||
|
||
const result = runGsdTools('validate health', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.ok(
|
||
output.warnings.some(w => w.code === 'W012'),
|
||
`Expected W012 in warnings: ${JSON.stringify(output.warnings)}`
|
||
);
|
||
});
|
||
|
||
test('W013: negative context_window triggers warning', () => {
|
||
writeMinimalProjectMd(tmpDir);
|
||
writeMinimalRoadmap(tmpDir, ['1']);
|
||
writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n');
|
||
writeValidConfigJson(tmpDir, { context_window: -500 });
|
||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
|
||
|
||
const result = runGsdTools('validate health', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.ok(
|
||
output.warnings.some(w => w.code === 'W013'),
|
||
`Expected W013 in warnings: ${JSON.stringify(output.warnings)}`
|
||
);
|
||
});
|
||
|
||
test('W014: phase_branch_template missing {phase} triggers warning', () => {
|
||
writeMinimalProjectMd(tmpDir);
|
||
writeMinimalRoadmap(tmpDir, ['1']);
|
||
writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n');
|
||
writeValidConfigJson(tmpDir, { phase_branch_template: 'gsd/no-placeholder-{slug}' });
|
||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
|
||
|
||
const result = runGsdTools('validate health', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.ok(
|
||
output.warnings.some(w => w.code === 'W014'),
|
||
`Expected W014 in warnings: ${JSON.stringify(output.warnings)}`
|
||
);
|
||
});
|
||
|
||
test('W015: milestone_branch_template missing {milestone} triggers warning', () => {
|
||
writeMinimalProjectMd(tmpDir);
|
||
writeMinimalRoadmap(tmpDir, ['1']);
|
||
writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n');
|
||
writeValidConfigJson(tmpDir, { milestone_branch_template: 'release/no-placeholder' });
|
||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
|
||
|
||
const result = runGsdTools('validate health', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.ok(
|
||
output.warnings.some(w => w.code === 'W015'),
|
||
`Expected W015 in warnings: ${JSON.stringify(output.warnings)}`
|
||
);
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 3. Boundary conditions
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('boundary conditions', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('context_window config accepts 500000 (boundary value)', () => {
|
||
writeMinimalProjectMd(tmpDir);
|
||
writeMinimalRoadmap(tmpDir, ['1']);
|
||
writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n');
|
||
writeValidConfigJson(tmpDir, { context_window: 500000 });
|
||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
|
||
|
||
const result = runGsdTools('validate health', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.ok(
|
||
!output.warnings.some(w => w.code === 'W013'),
|
||
`Should not have W013 for context_window=500000: ${JSON.stringify(output.warnings)}`
|
||
);
|
||
});
|
||
|
||
test('context_window config accepts 200000 (default value)', () => {
|
||
writeMinimalProjectMd(tmpDir);
|
||
writeMinimalRoadmap(tmpDir, ['1']);
|
||
writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n');
|
||
writeValidConfigJson(tmpDir, { context_window: 200000 });
|
||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
|
||
|
||
const result = runGsdTools('validate health', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.ok(
|
||
!output.warnings.some(w => w.code === 'W013'),
|
||
`Should not have W013 for context_window=200000: ${JSON.stringify(output.warnings)}`
|
||
);
|
||
});
|
||
|
||
test('W013 does NOT fire when context_window is absent from config', () => {
|
||
writeMinimalProjectMd(tmpDir);
|
||
writeMinimalRoadmap(tmpDir, ['1']);
|
||
writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n');
|
||
writeValidConfigJson(tmpDir);
|
||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
|
||
|
||
const result = runGsdTools('validate health', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.ok(
|
||
!output.warnings.some(w => w.code === 'W013'),
|
||
`Should not have W013 when context_window is absent: ${JSON.stringify(output.warnings)}`
|
||
);
|
||
});
|
||
|
||
test('health check handles STATE.md with no Current Phase field (no W011 crash)', () => {
|
||
writeMinimalProjectMd(tmpDir);
|
||
writeMinimalRoadmap(tmpDir, ['1']);
|
||
writeMinimalStateMd(tmpDir, '# Session State\n\nSome content but no phase reference.\n');
|
||
writeValidConfigJson(tmpDir);
|
||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
|
||
|
||
const result = runGsdTools('validate health', tmpDir);
|
||
assert.ok(result.success, `Command should not crash: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.ok(typeof output.status === 'string', 'should return a status string');
|
||
assert.ok(Array.isArray(output.errors), 'should return errors array');
|
||
assert.ok(Array.isArray(output.warnings), 'should return warnings array');
|
||
});
|
||
|
||
test('health check handles empty ROADMAP.md (no crash)', () => {
|
||
writeMinimalProjectMd(tmpDir);
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), '');
|
||
writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1.\n');
|
||
writeValidConfigJson(tmpDir);
|
||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
|
||
|
||
const result = runGsdTools('validate health', tmpDir);
|
||
assert.ok(result.success, `Command should not crash on empty ROADMAP.md: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.ok(typeof output.status === 'string', 'should return a status string');
|
||
assert.ok(Array.isArray(output.errors), 'should return errors array');
|
||
assert.ok(Array.isArray(output.warnings), 'should return warnings array');
|
||
});
|
||
|
||
test('config.json with trailing comma -- validate health reports parse error', () => {
|
||
writeMinimalProjectMd(tmpDir);
|
||
writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1.\n');
|
||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'config.json'),
|
||
'{"model_profile": "balanced",}'
|
||
);
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||
'# Roadmap\n\n### Phase 1: Test Phase\n'
|
||
);
|
||
|
||
const result = runGsdTools('validate health', tmpDir);
|
||
assert.ok(result.success, `validate health should not crash on invalid JSON: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
const hasE005 = output.errors.some(e => e.code === 'E005');
|
||
assert.ok(hasE005, `Should report E005 for invalid config.json: ${JSON.stringify(output.errors)}`);
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 4. stateReplaceFieldWithFallback warning
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('stateReplaceFieldWithFallback field-miss warning', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('advance-plan completes even when fields are missing (non-fatal)', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
`# Project State\n\n**Current Phase:** 01\n**Current Plan:** 1\n**Total Plans in Phase:** 3\n`
|
||
);
|
||
|
||
const result = runGsdTools('state advance-plan', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.ok(output.advanced === true || output.reason === 'last_plan', 'advance should complete');
|
||
});
|
||
|
||
test('validate health on 50-phase project completes in under 3000ms', () => {
|
||
// Stress test for the new health checks at scale
|
||
let roadmapContent = '# Roadmap v1.0\n\n';
|
||
for (let i = 1; i <= 50; i++) {
|
||
roadmapContent += `- [${i <= 25 ? 'x' : ' '}] Phase ${i}: Feature ${i}\n`;
|
||
}
|
||
roadmapContent += '\n';
|
||
for (let i = 1; i <= 50; i++) {
|
||
roadmapContent += `### Phase ${i}: Feature ${i}\n\n**Goal:** Build feature ${i}\n**Plans:** 1 plans\n\n`;
|
||
}
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmapContent);
|
||
|
||
writeMinimalProjectMd(tmpDir);
|
||
writeMinimalStateMd(tmpDir, '# Session State\n\n**Current Phase:** 26\n**Status:** Planning\n');
|
||
writeValidConfigJson(tmpDir);
|
||
|
||
const phasesDir = path.join(tmpDir, '.planning', 'phases');
|
||
for (let i = 1; i <= 50; i++) {
|
||
const pad = String(i).padStart(2, '0');
|
||
const phaseDir = path.join(phasesDir, `${pad}-feature-${i}`);
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
fs.writeFileSync(path.join(phaseDir, `${pad}-01-PLAN.md`), `# Plan ${i}\n`);
|
||
if (i <= 25) {
|
||
fs.writeFileSync(path.join(phaseDir, `${pad}-01-SUMMARY.md`), `# Summary ${i}\n`);
|
||
}
|
||
}
|
||
|
||
const result = runGsdTools('validate health', tmpDir);
|
||
|
||
assert.ok(result.success, `validate health should succeed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.ok(typeof output.status === 'string', 'Should return a status string');
|
||
});
|
||
});
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/6-validate-cjs-drift-regression.test.cjs — consolidation epic #1969 (B2 #1971)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:6-validate-cjs-drift-regression (consolidation epic #1969 B2 #1971)", () => {
|
||
'use strict';
|
||
|
||
/**
|
||
* Regression tests for issue #6 (open-gsd/gsd-core):
|
||
* Three validation behaviors present in validate.ts are missing from verify.cjs,
|
||
* producing silent false negatives on the CJS production path.
|
||
*
|
||
* Three drift items fixed by porting phaseVariants() and activeDiskPhases to verify.cjs:
|
||
*
|
||
* 1. W007 activeDiskPhases — verify.cjs uses diskPhases (includes archived) for the
|
||
* W007 check; validate.ts uses activeDiskPhases (active phasesDir only). Archived
|
||
* phases absent from current ROADMAP trigger false W007 in verify.cjs.
|
||
*
|
||
* 2. phaseVariants() normalization — validate.ts has a phaseVariants() function
|
||
* generating padded/unpadded/letter-suffix variants for matching. verify.cjs uses
|
||
* only parseInt→padded (drops letter suffix), causing false W006/W007 for
|
||
* letter-suffix phases with padding mismatch between ROADMAP and disk.
|
||
*
|
||
* 3. W006 unchecked-phase variant skip — same phaseVariants() gap causes false W006
|
||
* for phases with padding mismatch: ROADMAP says "3B", disk has "03B-foo", but
|
||
* verify.cjs padded("3B") = "03" (drops letter) → "03B" on disk not matched.
|
||
*
|
||
* References:
|
||
* - ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md)
|
||
* - Issue #6 (open-gsd/gsd-core)
|
||
* - PR #154 (issue #4) — precedent for the generator pattern
|
||
*/
|
||
|
||
const { describe, test, before, after } = 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 { runGsdTools, cleanup } = require('./helpers.cjs');
|
||
|
||
// ── Fixture helpers ──────────────────────────────────────────────────────────
|
||
|
||
function mkplanning(base) {
|
||
const planningDir = path.join(base, '.planning');
|
||
const phasesDir = path.join(planningDir, 'phases');
|
||
fs.mkdirSync(phasesDir, { recursive: true });
|
||
return { planningDir, phasesDir };
|
||
}
|
||
|
||
function writeProjectMd(planningDir) {
|
||
fs.writeFileSync(
|
||
path.join(planningDir, 'PROJECT.md'),
|
||
'# Project\n\n## What This Is\nTest.\n\n## Core Value\nTest.\n\n## Requirements\nTest.\n',
|
||
);
|
||
}
|
||
|
||
function writeStateMd(planningDir, phase = '2') {
|
||
fs.writeFileSync(
|
||
path.join(planningDir, 'STATE.md'),
|
||
`# State\n\n**Current Phase:** ${phase}\n**Status:** In progress\n`,
|
||
);
|
||
}
|
||
|
||
function writeConfigJson(planningDir) {
|
||
fs.writeFileSync(
|
||
path.join(planningDir, 'config.json'),
|
||
JSON.stringify({ model_profile: 'balanced' }),
|
||
);
|
||
}
|
||
|
||
// ── Drift Item 1: W007 activeDiskPhases ────────────────────────────────────
|
||
//
|
||
// Project has a shipped milestone archive (milestones/v1.0-phases/) with old phase
|
||
// "1" inside, and active phasesDir with phase "2". Current ROADMAP only mentions
|
||
// Phase 2 (v1.0 phases were shipped and removed from ROADMAP).
|
||
//
|
||
// validate.ts: activeDiskPhases = phases from active phasesDir only (not archived).
|
||
// "1" is only in diskPhases (via forEachArchivedPhaseToken), not activeDiskPhases.
|
||
// W007 iterates activeDiskPhases → "1" never checked → no false W007.
|
||
//
|
||
// verify.cjs pre-fix: diskPhases = collectDiskPhases() + forEachArchivedPhaseToken().
|
||
// diskPhases includes "1" (from old archive). W007 iterates diskPhases → "1" not
|
||
// in roadmapPhases → W007 fires for "1". False positive.
|
||
|
||
describe('Drift item 1 — W007 activeDiskPhases: no false W007 for archived phases', () => {
|
||
let tmpDir;
|
||
|
||
before(() => {
|
||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-6-d1-'));
|
||
const { planningDir, phasesDir } = mkplanning(tmpDir);
|
||
writeProjectMd(planningDir);
|
||
writeStateMd(planningDir);
|
||
writeConfigJson(planningDir);
|
||
|
||
fs.writeFileSync(
|
||
path.join(planningDir, 'ROADMAP.md'),
|
||
[
|
||
'# Roadmap',
|
||
'',
|
||
'- [x] **Phase 2:** API',
|
||
'',
|
||
'### Phase 2: API',
|
||
'',
|
||
'## Progress',
|
||
'| Phase | Status |',
|
||
'|-------|--------|',
|
||
'| 2 | Complete |',
|
||
].join('\n'),
|
||
);
|
||
|
||
// Active phasesDir: only phase 2
|
||
fs.mkdirSync(path.join(phasesDir, '2-api'), { recursive: true });
|
||
|
||
// Current active milestone archive: v1.1-phases contains phase 2 (in ROADMAP)
|
||
fs.mkdirSync(
|
||
path.join(planningDir, 'milestones', 'v1.1-phases', '2-api'),
|
||
{ recursive: true },
|
||
);
|
||
|
||
// Old shipped milestone archive: v1.0-phases contains phase 1 (no longer in ROADMAP)
|
||
// v1.0 sorts before v1.1 so getActiveMilestoneArchiveDir returns v1.1.
|
||
// forEachArchivedPhaseToken walks BOTH v1.0 and v1.1, so diskPhases gets "1".
|
||
// collectDiskPhases (activeDiskPhases) only uses v1.1 (active archive) — "1" excluded.
|
||
fs.mkdirSync(
|
||
path.join(planningDir, 'milestones', 'v1.0-phases', '1-foundation'),
|
||
{ recursive: true },
|
||
);
|
||
});
|
||
|
||
after(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('no W007 for archived phase "1" absent from current ROADMAP', () => {
|
||
// validate.ts: activeDiskPhases has only "2" (from active phasesDir + v1.1 archive).
|
||
// "1" is in diskPhases (forEachArchivedPhaseToken walks v1.0) but NOT activeDiskPhases.
|
||
// W007 iterates activeDiskPhases → "1" never checked → no false W007. Correct.
|
||
// verify.cjs pre-fix: diskPhases = collectDiskPhases + forEachArchivedPhaseToken.
|
||
// diskPhases includes "1" (from v1.0 archive). W007 iterates diskPhases → "1" not
|
||
// in roadmapPhases → W007 fires for "1". False positive.
|
||
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
|
||
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
|
||
const data = JSON.parse(result.output);
|
||
const w007 = (data.warnings ?? []).filter((w) => w.code === 'W007');
|
||
// Filter to W007 that mentions only phase "1" (not "1A", "01A", etc.)
|
||
const w007Phase1 = w007.filter(
|
||
(w) => /\bPhase 1\b/i.test(w.message) && !/\b1[A-Z]\b/i.test(w.message),
|
||
);
|
||
assert.strictEqual(
|
||
w007Phase1.length,
|
||
0,
|
||
`Expected no W007 for archived phase 1 (v1.0 archive), got: ${JSON.stringify(w007)}`,
|
||
);
|
||
});
|
||
});
|
||
|
||
// ── Drift Item 2: phaseVariants() normalization ────────────────────────────
|
||
//
|
||
// ROADMAP has "### Phase 01A:" (zero-padded letter-suffix heading).
|
||
// Disk has directory "1A-foo" (unpadded letter-suffix form).
|
||
// These should match because phaseVariants("01A") = {"01A", "1A", "01A"}.
|
||
//
|
||
// validate.ts: diskPhases has "1A". phaseVariants("01A") includes "1A" → match → no W006.
|
||
// activeDiskPhases has "1A". phaseVariants("1A") includes "01A" → roadmapPhaseVariants
|
||
// has "01A" → match → no W007.
|
||
//
|
||
// verify.cjs pre-fix:
|
||
// W006 loop for "01A": padded = String(parseInt("01A",10)).padStart(2,'0') = "01".
|
||
// diskPhases.has("01A")? NO. diskPhases.has("01")? NO. → W006 fires. Bug.
|
||
// W007 loop for "1A": unpadded = String(parseInt("1A",10)) = "1".
|
||
// roadmapPhases.has("1A")? NO. roadmapPhases.has("1")? NO. → W007 fires. Bug.
|
||
|
||
describe('Drift item 2 — phaseVariants() normalization: letter-suffix zero-padding mismatch', () => {
|
||
let tmpDir;
|
||
|
||
before(() => {
|
||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-6-d2-'));
|
||
const { planningDir, phasesDir } = mkplanning(tmpDir);
|
||
writeProjectMd(planningDir);
|
||
writeStateMd(planningDir);
|
||
writeConfigJson(planningDir);
|
||
|
||
// ROADMAP: Phase 01A (zero-padded + letter suffix)
|
||
fs.writeFileSync(
|
||
path.join(planningDir, 'ROADMAP.md'),
|
||
[
|
||
'# Roadmap',
|
||
'',
|
||
'- [x] **Phase 01A:** Suffix Phase',
|
||
'',
|
||
'### Phase 01A: Suffix Phase',
|
||
'',
|
||
'## Progress',
|
||
'| Phase | Status |',
|
||
'|-------|--------|',
|
||
'| 01A | Complete |',
|
||
].join('\n'),
|
||
);
|
||
|
||
// Disk: unpadded form "1A-foo"
|
||
fs.mkdirSync(path.join(phasesDir, '1A-suffix-phase'), { recursive: true });
|
||
});
|
||
|
||
after(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('no false W006 when ROADMAP says 01A and disk has 1A-... (phaseVariants normalizes)', () => {
|
||
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
|
||
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
|
||
const data = JSON.parse(result.output);
|
||
const w006 = (data.warnings ?? []).filter((w) => w.code === 'W006');
|
||
assert.strictEqual(
|
||
w006.length,
|
||
0,
|
||
`Expected no W006 (01A == 1A after normalization), got: ${JSON.stringify(w006)}`,
|
||
);
|
||
});
|
||
|
||
test('no false W007 when disk has 1A and ROADMAP says 01A (phaseVariants normalizes)', () => {
|
||
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
|
||
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
|
||
const data = JSON.parse(result.output);
|
||
const w007 = (data.warnings ?? []).filter((w) => w.code === 'W007');
|
||
assert.strictEqual(
|
||
w007.length,
|
||
0,
|
||
`Expected no W007 (1A on disk matches 01A in ROADMAP), got: ${JSON.stringify(w007)}`,
|
||
);
|
||
});
|
||
});
|
||
|
||
// ── Drift Item 3: W006 false positive when disk uses zero-padded letter form ─
|
||
//
|
||
// ROADMAP has "### Phase 3B:" (unpadded letter-suffix heading).
|
||
// Disk has directory "03B-feature" (zero-padded letter-suffix form).
|
||
//
|
||
// validate.ts:
|
||
// diskPhases has "03B". phaseVariants("3B") = {"3B","3B","03B"}.
|
||
// existsOnDisk = diskPhases.has("03B") = TRUE → no W006.
|
||
// activeDiskPhases has "03B". phaseVariants("03B") = {"03B","3B","03B"}.
|
||
// roadmapPhaseVariants has {"3B","3B","03B"} → "03B" found → no W007.
|
||
//
|
||
// verify.cjs pre-fix:
|
||
// W006 for "3B": padded = parseInt("3B")=3 → "03" (drops "B").
|
||
// diskPhases.has("3B")? NO. diskPhases.has("03")? NO (dir is "03B" not "03"). → W006 fires.
|
||
// W007 for "03B": unpadded = String(parseInt("03B",10)) = "3".
|
||
// roadmapPhases.has("03B")? NO. roadmapPhases.has("3")? NO (ROADMAP has "3B" not "3"). → W007.
|
||
|
||
describe('Drift item 3 — W006 false positive when disk has zero-padded letter form', () => {
|
||
let tmpDir;
|
||
|
||
before(() => {
|
||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-6-d3-'));
|
||
const { planningDir, phasesDir } = mkplanning(tmpDir);
|
||
writeProjectMd(planningDir);
|
||
writeStateMd(planningDir, '3B');
|
||
writeConfigJson(planningDir);
|
||
|
||
// ROADMAP: Phase 3B (unpadded in heading)
|
||
fs.writeFileSync(
|
||
path.join(planningDir, 'ROADMAP.md'),
|
||
[
|
||
'# Roadmap',
|
||
'',
|
||
'- [x] **Phase 3B:** Feature Extension',
|
||
'',
|
||
'### Phase 3B: Feature Extension',
|
||
'',
|
||
'## Progress',
|
||
'| Phase | Status |',
|
||
'|-------|--------|',
|
||
'| 3B | Complete |',
|
||
].join('\n'),
|
||
);
|
||
|
||
// Disk: "03B-feature" (zero-padded letter-suffix form)
|
||
fs.mkdirSync(path.join(phasesDir, '03B-feature'), { recursive: true });
|
||
});
|
||
|
||
after(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('no false W006 when ROADMAP says 3B and disk has 03B-... (phaseVariants covers zero-padded)', () => {
|
||
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
|
||
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
|
||
const data = JSON.parse(result.output);
|
||
const w006 = (data.warnings ?? []).filter((w) => w.code === 'W006');
|
||
assert.strictEqual(
|
||
w006.length,
|
||
0,
|
||
`Expected no W006 (3B == 03B after normalization), got: ${JSON.stringify(w006)}`,
|
||
);
|
||
});
|
||
|
||
test('no false W007 when disk has 03B and ROADMAP says 3B (phaseVariants covers both forms)', () => {
|
||
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
|
||
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
|
||
const data = JSON.parse(result.output);
|
||
const w007 = (data.warnings ?? []).filter((w) => w.code === 'W007');
|
||
assert.strictEqual(
|
||
w007.length,
|
||
0,
|
||
`Expected no W007 (03B on disk matches 3B in ROADMAP), got: ${JSON.stringify(w007)}`,
|
||
);
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/bug-416-archive-dir-null.test.cjs — consolidation epic #1969 (B2 #1971)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:bug-416-archive-dir-null (consolidation epic #1969 B2 #1971)", () => {
|
||
'use strict';
|
||
|
||
/**
|
||
* Regression tests for issue #416 (open-gsd/gsd-core).
|
||
*
|
||
* Bug: getActiveMilestoneArchiveDir falls back to the newest archive directory
|
||
* when STATE.md names a milestone that has no matching archive yet, producing
|
||
* W007 false positives for phases from a prior (completed) milestone.
|
||
*
|
||
* Fix: when STATE.md is present and parseable and names a milestone, but no
|
||
* milestones/<vX.Y>-phases/ directory matches, return null. The version-sort
|
||
* fallback to the newest archive fires only when STATE.md is absent or
|
||
* unparseable.
|
||
*
|
||
* Knuth invariant: the resolver answers one question —
|
||
* "what archive directory holds the active milestone's phases?"
|
||
* Answer space: <dir> | null.
|
||
*/
|
||
|
||
const { describe, test, before, after } = 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 { runGsdTools, cleanup } = require('./helpers.cjs');
|
||
|
||
// Consolidation #1969: scope GSD_TEST_MODE to this folded block so it does not
|
||
// leak (via the runGsdTools env copy) into host tests registered before the fold.
|
||
const __savedTestMode = process.env.GSD_TEST_MODE;
|
||
before(() => { process.env.GSD_TEST_MODE = '1'; });
|
||
after(() => {
|
||
if (__savedTestMode === undefined) delete process.env.GSD_TEST_MODE;
|
||
else process.env.GSD_TEST_MODE = __savedTestMode;
|
||
});
|
||
|
||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
function mkplanning(base) {
|
||
const planningDir = path.join(base, '.planning');
|
||
const phasesDir = path.join(planningDir, 'phases');
|
||
fs.mkdirSync(phasesDir, { recursive: true });
|
||
return planningDir;
|
||
}
|
||
|
||
function writeMinimalRoadmap(planningDir, phases) {
|
||
// phases: array of { num, name, checked }
|
||
const checkboxes = phases.map(({ num, name, checked }) =>
|
||
`- [${checked ? 'x' : ' '}] **Phase ${num}:** ${name}`,
|
||
).join('\n');
|
||
const headings = phases.map(({ num, name }) =>
|
||
`### Phase ${num}: ${name}\n**Goal:** Completed.\n`,
|
||
).join('\n');
|
||
fs.writeFileSync(
|
||
path.join(planningDir, 'ROADMAP.md'),
|
||
`# Roadmap\n\n${checkboxes}\n\n${headings}`,
|
||
);
|
||
}
|
||
|
||
function writeStateMdMilestone(planningDir, milestone) {
|
||
fs.writeFileSync(
|
||
path.join(planningDir, 'STATE.md'),
|
||
`# State\n\n**milestone:** ${milestone}\n**Current Phase:** 23\n**Status:** In progress\n`,
|
||
);
|
||
}
|
||
|
||
function writeProjectMd(planningDir) {
|
||
fs.writeFileSync(
|
||
path.join(planningDir, 'PROJECT.md'),
|
||
'# Project\n\n## What This Is\nTest.\n\n## Core Value\nTest.\n\n## Requirements\nTest.\n',
|
||
);
|
||
}
|
||
|
||
function writeConfigJson(planningDir) {
|
||
fs.writeFileSync(
|
||
path.join(planningDir, 'config.json'),
|
||
JSON.stringify({ model_profile: 'balanced' }),
|
||
);
|
||
}
|
||
|
||
function mkArchivePhases(planningDir, version, phaseNums) {
|
||
// Creates .planning/milestones/<version>-phases/<NN>-phase-name/ dirs
|
||
const archiveDir = path.join(planningDir, 'milestones', `${version}-phases`);
|
||
for (const num of phaseNums) {
|
||
const padded = String(num).padStart(2, '0');
|
||
fs.mkdirSync(path.join(archiveDir, `${padded}-phase-${num}`), { recursive: true });
|
||
}
|
||
return archiveDir;
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Case 1: STATE.md milestone: v6.0, only v5.0-phases/ on disk
|
||
// → resolver returns null, verifier emits zero W007 for phases 17–22
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('bug #416 case 1: STATE.md v6.0 with only v5.0-phases/ on disk → null, no W007', () => {
|
||
let tmpDir;
|
||
|
||
before(() => {
|
||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-416-c1-'));
|
||
const planningDir = mkplanning(tmpDir);
|
||
writeProjectMd(planningDir);
|
||
writeConfigJson(planningDir);
|
||
|
||
// Active milestone is v6.0 — no archive for it yet (phases live in flat phases/)
|
||
writeStateMdMilestone(planningDir, 'v6.0');
|
||
|
||
// v5.0 was the prior completed milestone; its archive exists on disk
|
||
mkArchivePhases(planningDir, 'v5.0', [17, 18, 19, 20, 21, 22]);
|
||
|
||
// ROADMAP reflects only v6.0 phases (v5.0 phases are in a prior milestone,
|
||
// not in the current roadmap section)
|
||
writeMinimalRoadmap(planningDir, [
|
||
{ num: 23, name: 'New Foundation', checked: false },
|
||
]);
|
||
});
|
||
|
||
after(() => { cleanup(tmpDir); });
|
||
|
||
test('validate health emits zero W007 warnings (no prior-milestone phases surfaced)', () => {
|
||
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
|
||
assert.strictEqual(result.success, true, `validate health failed: ${result.error}`);
|
||
const data = JSON.parse(result.output);
|
||
const w007 = (data.warnings ?? []).filter((w) => w.code === 'W007');
|
||
assert.strictEqual(
|
||
w007.length,
|
||
0,
|
||
`Expected zero W007 — phases 17–22 from v5.0-phases/ must not appear as "active".\nGot: ${JSON.stringify(w007, null, 2)}`,
|
||
);
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Case 2: No STATE.md, multiple archives on disk → version-sort fallback
|
||
// returns the highest-versioned archive (existing behavior preserved)
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('bug #416 case 2: no STATE.md + multiple archives → version-sort fallback to newest', () => {
|
||
let tmpDir;
|
||
|
||
before(() => {
|
||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-416-c2-'));
|
||
const planningDir = mkplanning(tmpDir);
|
||
writeProjectMd(planningDir);
|
||
writeConfigJson(planningDir);
|
||
|
||
// No STATE.md — resolver must use the version-sort fallback
|
||
// Two archives: v4.0 and v5.0; v5.0 is newer
|
||
mkArchivePhases(planningDir, 'v4.0', [10, 11, 12]);
|
||
mkArchivePhases(planningDir, 'v5.0', [17, 18, 19]);
|
||
|
||
// ROADMAP lists v5.0 phases so W007 does not fire
|
||
writeMinimalRoadmap(planningDir, [
|
||
{ num: 17, name: 'Alpha', checked: true },
|
||
{ num: 18, name: 'Beta', checked: true },
|
||
{ num: 19, name: 'Gamma', checked: true },
|
||
]);
|
||
});
|
||
|
||
after(() => { cleanup(tmpDir); });
|
||
|
||
test('validate health succeeds and does not emit W007 for v5.0 archive phases', () => {
|
||
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
|
||
assert.strictEqual(result.success, true, `validate health failed: ${result.error}`);
|
||
const data = JSON.parse(result.output);
|
||
const w007 = (data.warnings ?? []).filter((w) => w.code === 'W007');
|
||
// v5.0 phases (17–19) are in the archive returned by the fallback and
|
||
// in the ROADMAP, so no W007 should fire.
|
||
assert.strictEqual(
|
||
w007.length,
|
||
0,
|
||
`Expected zero W007 for v5.0 archive phases present in ROADMAP.\nGot: ${JSON.stringify(w007, null, 2)}`,
|
||
);
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Case 3: STATE.md milestone: v5.0, matching v5.0-phases/ exists → returns it
|
||
// (regression guard — happy path must not break)
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('bug #416 case 3: STATE.md v5.0 with matching v5.0-phases/ → returns archive dir', () => {
|
||
let tmpDir;
|
||
|
||
before(() => {
|
||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-416-c3-'));
|
||
const planningDir = mkplanning(tmpDir);
|
||
writeProjectMd(planningDir);
|
||
writeConfigJson(planningDir);
|
||
|
||
// STATE.md names v5.0 and a matching archive exists
|
||
writeStateMdMilestone(planningDir, 'v5.0');
|
||
mkArchivePhases(planningDir, 'v5.0', [17, 18, 19, 20, 21, 22]);
|
||
|
||
// ROADMAP lists v5.0 phases so W007 does not fire
|
||
writeMinimalRoadmap(planningDir, [
|
||
{ num: 17, name: 'Alpha', checked: true },
|
||
{ num: 18, name: 'Beta', checked: true },
|
||
{ num: 19, name: 'Gamma', checked: true },
|
||
{ num: 20, name: 'Delta', checked: true },
|
||
{ num: 21, name: 'Epsilon', checked: true },
|
||
{ num: 22, name: 'Zeta', checked: true },
|
||
]);
|
||
});
|
||
|
||
after(() => { cleanup(tmpDir); });
|
||
|
||
test('validate health emits zero W007 — archive phases are in ROADMAP and active', () => {
|
||
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
|
||
assert.strictEqual(result.success, true, `validate health failed: ${result.error}`);
|
||
const data = JSON.parse(result.output);
|
||
const w007 = (data.warnings ?? []).filter((w) => w.code === 'W007');
|
||
assert.strictEqual(
|
||
w007.length,
|
||
0,
|
||
`Expected zero W007 for matching v5.0 archive with v5.0 in STATE.md.\nGot: ${JSON.stringify(w007, null, 2)}`,
|
||
);
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/26-w005-w006-i001-cjs-drift-regression.test.cjs — consolidation epic #1969 (B3 #1972)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:26-w005-w006-i001-cjs-drift-regression (consolidation epic #1969 B3 #1972)", () => {
|
||
'use strict';
|
||
|
||
/**
|
||
* Regression tests for issue #26 (open-gsd/gsd-core).
|
||
* Three generator-pattern drift items: W005 phaseDirNameRe,
|
||
* W006-archived regex constants (PHASE_TOKEN_FROM_DIR_RE, MILESTONE_ARCHIVE_DIR_RE),
|
||
* I001 canonicalPlanStem.
|
||
*
|
||
* After the generator migration, all three helpers are sourced from
|
||
* validate.cjs. If they diverge from validate.ts, these
|
||
* tests go RED.
|
||
*
|
||
* References:
|
||
* - Issue #26 (open-gsd/gsd-core) — three drift items + reproducer
|
||
* - ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md)
|
||
* - PR #154 (issue #4) — generator pattern precedent
|
||
* - PR #156 (issue #6) — validate.ts generator scaffolding (#26 extends this)
|
||
* - Original PR #3479 — first validate.ts false-positive fix (never reached CJS)
|
||
*/
|
||
|
||
const { describe, test, before, after } = 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 { runGsdTools, cleanup } = require('./helpers.cjs');
|
||
|
||
function mkplanning(base) {
|
||
const planningDir = path.join(base, '.planning');
|
||
const phasesDir = path.join(planningDir, 'phases');
|
||
fs.mkdirSync(phasesDir, { recursive: true });
|
||
return { planningDir, phasesDir };
|
||
}
|
||
|
||
function writeProjectMd(planningDir) {
|
||
fs.writeFileSync(
|
||
path.join(planningDir, 'PROJECT.md'),
|
||
'# Project\n\n## What This Is\nTest.\n\n## Core Value\nTest.\n\n## Requirements\nTest.\n',
|
||
);
|
||
}
|
||
|
||
function writeStateMd(planningDir, phase) {
|
||
fs.writeFileSync(
|
||
path.join(planningDir, 'STATE.md'),
|
||
`# State\n\n**Current Phase:** ${phase}\n**Status:** In progress\n`,
|
||
);
|
||
}
|
||
|
||
function writeConfigJson(planningDir) {
|
||
fs.writeFileSync(
|
||
path.join(planningDir, 'config.json'),
|
||
JSON.stringify({ model_profile: 'balanced' }),
|
||
);
|
||
}
|
||
|
||
// ── Drift Item W005: phaseDirNameRe ──────────────────────────────────────────
|
||
//
|
||
// Issue #26 reproducer (verbatim):
|
||
// mkdir -p .planning/phases/999.1-foo
|
||
// echo "# Roadmap" > .planning/ROADMAP.md
|
||
// node .claude/gsd-core/bin/gsd-tools.cjs validate health
|
||
// # Bug: emits W005 about 999.1-foo not following NN-name format
|
||
//
|
||
// verify.cjs must consume phaseDirNameRe from validate.cjs so
|
||
// the regex /^\d{2,}(?:\.\d+)*-[\w-]+$/ is the single source of truth.
|
||
|
||
describe('Drift item W005 — phaseDirNameRe: 999.X-name dirs must not trigger W005', () => {
|
||
let tmpDir;
|
||
|
||
before(() => {
|
||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-26-d1-'));
|
||
const { planningDir, phasesDir } = mkplanning(tmpDir);
|
||
writeProjectMd(planningDir);
|
||
writeStateMd(planningDir, '999.1');
|
||
writeConfigJson(planningDir);
|
||
|
||
fs.writeFileSync(
|
||
path.join(planningDir, 'ROADMAP.md'),
|
||
'# Roadmap\n\n- [x] **Phase 999.1:** Long Phase\n\n### Phase 999.1: Long Phase\n',
|
||
);
|
||
|
||
// Exact reproducer from issue #26
|
||
fs.mkdirSync(path.join(phasesDir, '999.1-foo'), { recursive: true });
|
||
});
|
||
|
||
after(() => { cleanup(tmpDir); });
|
||
|
||
test('no W005 for 999.1-foo (multi-digit sub-phase prefix)', () => {
|
||
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
|
||
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
|
||
const data = JSON.parse(result.output);
|
||
const w005 = (data.warnings ?? []).filter((w) => w.code === 'W005');
|
||
assert.strictEqual(w005.length, 0,
|
||
`Expected zero W005 for 999.1-foo, got: ${JSON.stringify(w005)}`);
|
||
});
|
||
|
||
test('phaseDirNameRe is exported from validate.cjs', () => {
|
||
const gen = require('../gsd-core/bin/lib/validate.cjs');
|
||
assert.ok(gen.phaseDirNameRe instanceof RegExp,
|
||
'validate.cjs must export phaseDirNameRe as a RegExp');
|
||
const re = gen.phaseDirNameRe;
|
||
assert.ok(re.test('01-setup'), 'should accept 01-setup');
|
||
assert.ok(re.test('999-longphase'), 'should accept 999-longphase (3-digit prefix)');
|
||
assert.ok(re.test('999.1-foo'), 'should accept 999.1-foo (sub-phase)');
|
||
assert.ok(re.test('MANIFOLD-999.1-foo'), 'should accept long project-code prefixes');
|
||
assert.ok(re.test('APP1-999.1-foo'), 'should accept numeric characters in project-code prefixes');
|
||
assert.ok(re.test('APP_1-999.1-foo'), 'should accept underscore characters in project-code prefixes');
|
||
assert.ok(!re.test('1-shortname'), 'should reject single-digit prefix');
|
||
});
|
||
});
|
||
|
||
// ── W023 (#2408): collision warning for two on-disk phase dirs normalizing to
|
||
// the same phase key. The check groups phaseDirEntries by their normalized
|
||
// key (normalizePhaseName) and emits a warning for any group with ≥2 dirs,
|
||
// listing both directory names and each one's independently-computed status.
|
||
// Wording is neutral — never guesses which directory is "real".
|
||
describe('W023 — colliding phase directories (issue #2408)', () => {
|
||
let tmpDir;
|
||
|
||
before(() => {
|
||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2408-'));
|
||
const { planningDir, phasesDir } = mkplanning(tmpDir);
|
||
writeProjectMd(planningDir);
|
||
writeStateMd(planningDir, '1.0');
|
||
writeConfigJson(planningDir);
|
||
|
||
fs.writeFileSync(
|
||
path.join(planningDir, 'ROADMAP.md'),
|
||
[
|
||
'# Roadmap',
|
||
'',
|
||
'## Milestone v1.0',
|
||
'',
|
||
'### Phase 5: Real',
|
||
'**Goal:** The real phase',
|
||
].join('\n'),
|
||
);
|
||
|
||
// 05-real/ — Complete (PLAN + SUMMARY + passing VERIFICATION)
|
||
const realDir = path.join(phasesDir, '05-real');
|
||
fs.mkdirSync(realDir, { recursive: true });
|
||
fs.writeFileSync(path.join(realDir, '01-01-PLAN.md'), '# Plan');
|
||
fs.writeFileSync(path.join(realDir, '01-01-SUMMARY.md'), '# Summary');
|
||
// Real convention is `*-VERIFICATION.md` (gsd-core/workflows/verify-work.md:573's
|
||
// `ls "${PHASE_DIR}"/*-VERIFICATION.md` glob; verification.cts:425's
|
||
// `readVerificationStatus` matches the same `-VERIFICATION.md` suffix) — a bare
|
||
// `VERIFICATION.md` with no prefix is never produced by /gsd-verify-work and is
|
||
// invisible to the canonical reader `isPhaseComplete` now sources this rule from.
|
||
fs.writeFileSync(path.join(realDir, '05-real-VERIFICATION.md'), '---\nstatus: passed\n---\n# Verified');
|
||
|
||
// 05-real-stray/ — empty → Not Started
|
||
fs.mkdirSync(path.join(phasesDir, '05-real-stray'), { recursive: true });
|
||
});
|
||
|
||
after(() => { cleanup(tmpDir); });
|
||
|
||
test('emits W023 naming both colliding directories and their statuses', () => {
|
||
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
|
||
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
|
||
const data = JSON.parse(result.output);
|
||
const w023 = (data.warnings ?? []).filter((w) => w.code === 'W023');
|
||
assert.strictEqual(w023.length, 1, `Expected exactly one W023, got: ${JSON.stringify(w023)}`);
|
||
const msg = w023[0].message;
|
||
assert.ok(msg.includes('"05"'), `W023 message must name the normalized phase key, got: ${msg}`);
|
||
assert.ok(msg.includes('05-real'), `W023 must name the real dir, got: ${msg}`);
|
||
assert.ok(msg.includes('05-real-stray'), `W023 must name the stray dir, got: ${msg}`);
|
||
// Independently-computed statuses appear alongside each dir.
|
||
assert.ok(msg.includes('Complete'), `W023 must include 05-real's Complete status, got: ${msg}`);
|
||
assert.ok(msg.includes('Not Started'), `W023 must include 05-real-stray's Not Started status, got: ${msg}`);
|
||
// Neutral wording across every rendered field (message AND fix/action) —
|
||
// never guesses which dir is "the real one" or names a specific dir as the
|
||
// one to remove. The check scans the union of message+fix so a future drift
|
||
// toward "remove 05-real-stray" in either field fails this test.
|
||
const combined = `${w023[0].message} ${w023[0].fix || ''}`;
|
||
assert.ok(!/real directory is/i.test(combined) && !/remove\s+05-real-stray/i.test(combined),
|
||
`W023 wording must stay neutral across all rendered fields, got: ${combined}`);
|
||
});
|
||
|
||
test('W023 listing order is deterministic across readdirSync orders (tie-break on dir name)', () => {
|
||
// Re-running validate health should produce the same W023 message byte-for-
|
||
// byte. The collision case itself (two dirs sharing the same phase token)
|
||
// is exactly where comparePhaseNum returns 0 — the dir-name tie-break is
|
||
// what keeps the comma-separated order stable. fs.readdirSync order is
|
||
// non-deterministic across platforms; without the tie-break, this test
|
||
// could flake on some hosts. Run twice and assert identical output.
|
||
const r1 = runGsdTools(['validate', 'health', '--json'], tmpDir);
|
||
const r2 = runGsdTools(['validate', 'health', '--json'], tmpDir);
|
||
assert.strictEqual(r1.success && r2.success, true);
|
||
const m1 = JSON.parse(r1.output).warnings.find((w) => w.code === 'W023').message;
|
||
const m2 = JSON.parse(r2.output).warnings.find((w) => w.code === 'W023').message;
|
||
assert.strictEqual(m1, m2, `W023 must be deterministic across runs, got:\n ${m1}\n ${m2}`);
|
||
// And specifically: the lower-name dir comes first under localeCompare.
|
||
assert.ok(m1.indexOf('05-real (') < m1.indexOf('05-real-stray ('),
|
||
`tie-break must place '05-real' before '05-real-stray' (localeCompare), got: ${m1}`);
|
||
});
|
||
|
||
test('does NOT emit W023 when no collision exists (single dir per key)', () => {
|
||
// Fresh tree with a single non-colliding dir.
|
||
const clean = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2408-clean-'));
|
||
const { planningDir, phasesDir } = mkplanning(clean);
|
||
writeProjectMd(planningDir);
|
||
writeStateMd(planningDir, '1.0');
|
||
writeConfigJson(planningDir);
|
||
fs.writeFileSync(
|
||
path.join(planningDir, 'ROADMAP.md'),
|
||
'# Roadmap\n\n## Milestone v1.0\n\n### Phase 5: Real\n**Goal:** The real phase\n',
|
||
);
|
||
const realDir = path.join(phasesDir, '05-real');
|
||
fs.mkdirSync(realDir, { recursive: true });
|
||
fs.writeFileSync(path.join(realDir, '01-01-PLAN.md'), '# Plan');
|
||
fs.writeFileSync(path.join(realDir, '01-01-SUMMARY.md'), '# Summary');
|
||
|
||
try {
|
||
const result = runGsdTools(['validate', 'health', '--json'], clean);
|
||
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
|
||
const data = JSON.parse(result.output);
|
||
const w023 = (data.warnings ?? []).filter((w) => w.code === 'W023');
|
||
assert.strictEqual(w023.length, 0, `Expected zero W023 without collision, got: ${JSON.stringify(w023)}`);
|
||
} finally {
|
||
cleanup(clean);
|
||
}
|
||
});
|
||
});
|
||
|
||
// ── Drift Item W006-archived: PHASE_TOKEN_FROM_DIR_RE / MILESTONE_ARCHIVE_DIR_RE ─
|
||
//
|
||
// forEachArchivedPhaseToken() in verify.cjs uses two inline regex constants.
|
||
// After migration both are sourced from validate.cjs:
|
||
// PHASE_TOKEN_FROM_DIR_RE — extracts token from dir name like "64-auth-service"
|
||
// MILESTONE_ARCHIVE_DIR_RE — matches archive dirs like "v1.0-phases"
|
||
//
|
||
// Test: phase 64 was archived to milestones/v1.0-phases/64-auth-service/.
|
||
// Without correct archive detection, W006 fires for "Phase 64 in ROADMAP.md
|
||
// but no directory on disk".
|
||
|
||
describe('Drift item W006-archived — MILESTONE_ARCHIVE_DIR_RE and PHASE_TOKEN_FROM_DIR_RE', () => {
|
||
let tmpDir;
|
||
|
||
before(() => {
|
||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-26-d2-'));
|
||
const { planningDir, phasesDir } = mkplanning(tmpDir);
|
||
writeProjectMd(planningDir);
|
||
writeStateMd(planningDir, '65');
|
||
writeConfigJson(planningDir);
|
||
|
||
fs.writeFileSync(
|
||
path.join(planningDir, 'ROADMAP.md'),
|
||
[
|
||
'# Roadmap',
|
||
'',
|
||
'- [x] **Phase 65:** Current Work',
|
||
'',
|
||
'### Phase 65: Current Work',
|
||
'',
|
||
'<details>',
|
||
'<summary>Milestone v1.0 — Shipped</summary>',
|
||
'',
|
||
'### Phase 64: Auth Service',
|
||
'',
|
||
'</details>',
|
||
'',
|
||
].join('\n'),
|
||
);
|
||
|
||
// Active phasesDir: only phase 65
|
||
fs.mkdirSync(path.join(phasesDir, '65-current-work'), { recursive: true });
|
||
|
||
// Archive: milestones/v1.0-phases/64-auth-service
|
||
// MILESTONE_ARCHIVE_DIR_RE must match "v1.0-phases"
|
||
// PHASE_TOKEN_FROM_DIR_RE must extract "64" from "64-auth-service"
|
||
fs.mkdirSync(
|
||
path.join(planningDir, 'milestones', 'v1.0-phases', '64-auth-service'),
|
||
{ recursive: true },
|
||
);
|
||
});
|
||
|
||
after(() => { cleanup(tmpDir); });
|
||
|
||
test('no W006 for Phase 64 archived under milestones/v1.0-phases/', () => {
|
||
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
|
||
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
|
||
const data = JSON.parse(result.output);
|
||
const w006 = (data.warnings ?? []).filter(
|
||
(w) => w.code === 'W006' && /Phase 64/i.test(w.message),
|
||
);
|
||
assert.strictEqual(w006.length, 0,
|
||
`Expected no W006 for archived Phase 64, got: ${JSON.stringify(w006)}`);
|
||
});
|
||
|
||
test('MILESTONE_ARCHIVE_DIR_RE is exported and matches vN.N-phases dirs', () => {
|
||
const gen = require('../gsd-core/bin/lib/validate.cjs');
|
||
assert.ok(gen.MILESTONE_ARCHIVE_DIR_RE instanceof RegExp,
|
||
'validate.cjs must export MILESTONE_ARCHIVE_DIR_RE');
|
||
const re = gen.MILESTONE_ARCHIVE_DIR_RE;
|
||
assert.ok(re.test('v1.0-phases'), 'should match v1.0-phases');
|
||
assert.ok(re.test('v1.10-phases'), 'should match v1.10-phases');
|
||
assert.ok(!re.test('phases'), 'should NOT match plain phases');
|
||
assert.ok(!re.test('1.0-phases'), 'should NOT match missing v prefix');
|
||
});
|
||
|
||
test('PHASE_TOKEN_FROM_DIR_RE is exported and extracts phase tokens correctly', () => {
|
||
const gen = require('../gsd-core/bin/lib/validate.cjs');
|
||
assert.ok(gen.PHASE_TOKEN_FROM_DIR_RE instanceof RegExp,
|
||
'validate.cjs must export PHASE_TOKEN_FROM_DIR_RE');
|
||
const re = gen.PHASE_TOKEN_FROM_DIR_RE;
|
||
assert.strictEqual(re.exec('64-auth-service')?.[1], '64');
|
||
assert.strictEqual(re.exec('03B-feature')?.[1], '03B');
|
||
assert.strictEqual(re.exec('999.1-foo')?.[1], '999.1');
|
||
assert.strictEqual(re.exec('CK-64-auth')?.[1], 'CK-64');
|
||
assert.strictEqual(re.exec('MANIFOLD-64-auth')?.[1], 'MANIFOLD-64');
|
||
assert.strictEqual(re.exec('APP1-64-auth')?.[1], 'APP1-64');
|
||
assert.strictEqual(re.exec('APP_1-64-auth')?.[1], 'APP_1-64');
|
||
});
|
||
|
||
test('PHASE_TOKEN_FROM_DIR_RE rejects a single-digit slug word after a phase number (#2043)', () => {
|
||
const gen = require('../gsd-core/bin/lib/validate.cjs');
|
||
const re = gen.PHASE_TOKEN_FROM_DIR_RE;
|
||
// Roadmap phase name "6 Rs Pipeline Orchestrator" slugifies to
|
||
// "6-rs-pipeline-orchestrator"; the resulting dir "46-6-rs-pipeline-orchestrator"
|
||
// must extract phase token "46", not "46-6" (was the pre-fix, buggy behavior).
|
||
assert.strictEqual(re.exec('46-6-rs-pipeline-orchestrator')?.[1], '46');
|
||
// Legit multi-segment (zero-padded) milestone-prefixed tokens are preserved.
|
||
assert.strictEqual(re.exec('02-01-setup')?.[1], '02-01');
|
||
// Single-digit letter-suffix phase ids ("1A"/"01A") and milestone-prefixed
|
||
// single-digit sub-phases ("M1-2") must still match (the fix tightens
|
||
// only the continuation, not the first component).
|
||
assert.strictEqual(re.exec('1A-foo')?.[1], '1A');
|
||
assert.strictEqual(re.exec('01A-foo')?.[1], '01A');
|
||
assert.strictEqual(re.exec('M1-2-setup')?.[1], 'M1-2');
|
||
});
|
||
});
|
||
|
||
// ── Drift Item I001: canonicalPlanStem ────────────────────────────────────────
|
||
//
|
||
// validate.ts Check 7: canonicalPlanStem('68-01-scaffolding') → '68-01'
|
||
// verify.cjs had an inline copy. After migration, canonicalPlanStem is
|
||
// sourced from validate.cjs.
|
||
//
|
||
// Test: "68-01-scaffolding-PLAN.md" + "68-01-SUMMARY.md" → no I001
|
||
// Both stems canonicalize to "68-01" → match found → I001 suppressed.
|
||
|
||
describe('Drift item I001 — canonicalPlanStem: long PLAN stem matches short SUMMARY stem', () => {
|
||
let tmpDir;
|
||
|
||
before(() => {
|
||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-26-d3-'));
|
||
const { planningDir, phasesDir } = mkplanning(tmpDir);
|
||
writeProjectMd(planningDir);
|
||
writeStateMd(planningDir, '68');
|
||
writeConfigJson(planningDir);
|
||
|
||
fs.writeFileSync(
|
||
path.join(planningDir, 'ROADMAP.md'),
|
||
'# Roadmap\n\n- [x] **Phase 68:** Scaffolding\n\n### Phase 68: Scaffolding\n',
|
||
);
|
||
|
||
const phaseDir = path.join(phasesDir, '68-scaffolding');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
|
||
// Long-stem PLAN + short-stem SUMMARY → must match via canonicalPlanStem
|
||
fs.writeFileSync(path.join(phaseDir, '68-01-scaffolding-PLAN.md'), '---\nwave: 1\n---\n# Plan\n');
|
||
fs.writeFileSync(path.join(phaseDir, '68-01-SUMMARY.md'), '# Summary\n');
|
||
});
|
||
|
||
after(() => { cleanup(tmpDir); });
|
||
|
||
test('no I001 when 68-01-scaffolding-PLAN.md matches 68-01-SUMMARY.md via canonicalPlanStem', () => {
|
||
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
|
||
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
|
||
const data = JSON.parse(result.output);
|
||
const i001 = (data.info ?? []).filter((i) => i.code === 'I001');
|
||
assert.strictEqual(i001.length, 0,
|
||
`Expected zero I001, got: ${JSON.stringify(i001)}`);
|
||
});
|
||
|
||
test('canonicalPlanStem is exported from validate.cjs', () => {
|
||
const gen = require('../gsd-core/bin/lib/validate.cjs');
|
||
assert.strictEqual(typeof gen.canonicalPlanStem, 'function',
|
||
'validate.cjs must export canonicalPlanStem as a function');
|
||
assert.strictEqual(gen.canonicalPlanStem('68-01-scaffolding'), '68-01');
|
||
assert.strictEqual(gen.canonicalPlanStem('68-01'), '68-01');
|
||
assert.strictEqual(gen.canonicalPlanStem('3A-01-feature'), '3A-01');
|
||
assert.strictEqual(gen.canonicalPlanStem('no-match'), 'no-match');
|
||
});
|
||
|
||
test('canonicalPlanStem rejects a single-digit slug word after a phase number (#2043)', () => {
|
||
const gen = require('../gsd-core/bin/lib/validate.cjs');
|
||
// "46-6-rs-pipeline-orchestrator" is not a valid PLAN stem shape (the "6"
|
||
// is a slug word, not a sub-phase segment), so it must return the input
|
||
// unchanged rather than the pre-fix, buggy "46-6".
|
||
assert.notStrictEqual(gen.canonicalPlanStem('46-6-rs-pipeline-orchestrator'), '46-6');
|
||
// Legit multi-segment stems are still canonicalized correctly, including a
|
||
// single-digit letter-suffix phase id ("3A") whose plan component is zero-padded.
|
||
assert.strictEqual(gen.canonicalPlanStem('68-01-scaffolding'), '68-01');
|
||
assert.strictEqual(gen.canonicalPlanStem('3A-01-feature'), '3A-01');
|
||
});
|
||
|
||
test('PHASE_TOKEN_FROM_DIR_RE rejects a ≥3-digit slug word after a phase number (#2232)', () => {
|
||
const gen = require('../gsd-core/bin/lib/validate.cjs');
|
||
const re = gen.PHASE_TOKEN_FROM_DIR_RE;
|
||
// Dir "14-2026-photos-performance" (roadmap phase name "2026 Photos &
|
||
// Performance") must extract token "14", not "14-2026" — the year is the
|
||
// slug's first word. Boundary by continuation-segment digit width:
|
||
assert.strictEqual(re.exec('14-2026-photos-performance')?.[1], '14'); // 4-digit: slug
|
||
assert.strictEqual(re.exec('05-100-slug')?.[1], '05'); // 3-digit: slug (policy)
|
||
assert.strictEqual(re.exec('02-01-setup')?.[1], '02-01'); // 2-digit: sub-phase
|
||
assert.strictEqual(re.exec('46-6-rs')?.[1], '46'); // 1-digit: slug (#2043)
|
||
});
|
||
|
||
test('PHASE_TOKEN_FROM_DIR_RE reads a 2-digit segment literally, whatever follows (#2528)', () => {
|
||
const gen = require('../gsd-core/bin/lib/validate.cjs');
|
||
const re = gen.PHASE_TOKEN_FROM_DIR_RE;
|
||
// A 1-digit word after the continuation is not evidence about the
|
||
// continuation: "10-24-7-autonomy" (phase 10, slug "24/7 Autonomy") and
|
||
// "10-24-7-zip" (sub-phase 10.24, slug "7-Zip") are the same string shape.
|
||
assert.strictEqual(re.exec('10-24-7-autonomy')?.[1], '10-24');
|
||
assert.strictEqual(re.exec('10-24-7-zip')?.[1], '10-24');
|
||
// Lowercase inside the segment IS evidence — the write side never emits it.
|
||
assert.strictEqual(re.exec('05-80-20-25abc')?.[1], '05-80-20');
|
||
});
|
||
|
||
test('canonicalPlanStem does not pair a ≥3-digit slug word (#2232)', () => {
|
||
const gen = require('../gsd-core/bin/lib/validate.cjs');
|
||
// A year-leading slug is not a plan component: the stem is returned
|
||
// unchanged rather than the bogus "14-2026".
|
||
assert.strictEqual(
|
||
gen.canonicalPlanStem('14-2026-photos-performance'),
|
||
'14-2026-photos-performance',
|
||
);
|
||
assert.strictEqual(gen.canonicalPlanStem('05-100-slug'), '05-100-slug');
|
||
// Legit zero-padded plan components still canonicalize.
|
||
assert.strictEqual(gen.canonicalPlanStem('68-01-scaffolding'), '68-01');
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/bug-663-redos-roadmap-phase-parsing.test.cjs — consolidation epic #1969 (B3 #1972)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:bug-663-redos-roadmap-phase-parsing (consolidation epic #1969 B3 #1972)", () => {
|
||
/**
|
||
* Regression test for the ReDoS fixes in buildRoadmapPhaseVariants() and
|
||
* buildNotStartedPhaseVariants() (src/validate.cts, fix #663).
|
||
*
|
||
* The old patterns used nested quantifiers that caused catastrophic
|
||
* backtracking on crafted input:
|
||
* old: [\w][\w.-]*(?:-[\w.-]+)* ← ambiguous alternation → exponential
|
||
* new: [\w][\w.-]* ← single quantifier → linear
|
||
*
|
||
* The same nested-quantifier shape was fixed identically in src/verify.cts,
|
||
* src/commands.cts, and src/phase.cts.
|
||
*
|
||
* Part A: behavior preservation — the collapsed regex still matches the same
|
||
* phase identifiers as before on normal roadmap content.
|
||
* Part B: ReDoS adversarial fixtures — calls buildRoadmapPhaseVariants /
|
||
* buildNotStartedPhaseVariants with pathological input (a malformed heading
|
||
* or checklist line that has NO terminating colon). The adversarial input
|
||
* would cause catastrophic backtracking under the OLD nested-quantifier
|
||
* pattern; the fix makes backtracking linear. We assert on the STRUCTURED
|
||
* RESULT (the returned Set is empty — no match — because the colon is
|
||
* absent) rather than on elapsed time, in compliance with the
|
||
* local/no-elapsed-assertion ESLint rule. A { timeout: 5000 } backstop is
|
||
* retained so the test fails fast if a future regression reintroduces a
|
||
* slow pattern.
|
||
*
|
||
* Requirements: TEST-663-B
|
||
*/
|
||
|
||
const { test, describe } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const {
|
||
buildRoadmapPhaseVariants,
|
||
buildNotStartedPhaseVariants,
|
||
} = require('../gsd-core/bin/lib/validate.cjs');
|
||
|
||
// ─── Part A: behavior preservation ───────────────────────────────────────────
|
||
|
||
describe('buildRoadmapPhaseVariants — behavior preservation (#663)', () => {
|
||
test('matches plain numeric heading (Phase 1:)', () => {
|
||
const content = [
|
||
'# Roadmap',
|
||
'## Phase 1: Foo',
|
||
].join('\n');
|
||
|
||
const { roadmapPhases } = buildRoadmapPhaseVariants(content);
|
||
assert.ok(roadmapPhases.has('1'), 'roadmapPhases should contain "1"');
|
||
});
|
||
|
||
test('matches milestone-prefixed heading (Phase 2-01:)', () => {
|
||
const content = [
|
||
'# Roadmap',
|
||
'### Phase 2-01: Bar',
|
||
].join('\n');
|
||
|
||
const { roadmapPhases } = buildRoadmapPhaseVariants(content);
|
||
assert.ok(roadmapPhases.has('2-01'), 'roadmapPhases should contain "2-01"');
|
||
});
|
||
|
||
test('matches bracket-prefixed heading ([GSD] Phase 3.2:)', () => {
|
||
const content = [
|
||
'# Roadmap',
|
||
'### [GSD] Phase 3.2: Baz',
|
||
].join('\n');
|
||
|
||
const { roadmapPhases } = buildRoadmapPhaseVariants(content);
|
||
assert.ok(roadmapPhases.has('3.2'), 'roadmapPhases should contain "3.2"');
|
||
});
|
||
|
||
test('collects all phase identifiers from mixed-format roadmap', () => {
|
||
const content = [
|
||
'# Roadmap',
|
||
'## Phase 1: Alpha',
|
||
'### Phase 2-01: Beta',
|
||
'### [GSD] Phase 3.2: Gamma',
|
||
].join('\n');
|
||
|
||
const { roadmapPhases } = buildRoadmapPhaseVariants(content);
|
||
assert.ok(roadmapPhases.has('1'), 'should have phase 1');
|
||
assert.ok(roadmapPhases.has('2-01'), 'should have phase 2-01');
|
||
assert.ok(roadmapPhases.has('3.2'), 'should have phase 3.2');
|
||
});
|
||
|
||
test('populates roadmapPhaseVariants with padding-normalized forms', () => {
|
||
const content = [
|
||
'# Roadmap',
|
||
'### Phase 2-01: Beta',
|
||
].join('\n');
|
||
|
||
const { roadmapPhaseVariants } = buildRoadmapPhaseVariants(content);
|
||
// phaseVariants() adds both padded and unpadded forms
|
||
assert.ok(roadmapPhaseVariants.has('2-01') || roadmapPhaseVariants.has('02-01'),
|
||
'roadmapPhaseVariants should contain at least one padding form of 2-01');
|
||
});
|
||
});
|
||
|
||
describe('buildNotStartedPhaseVariants — behavior preservation (#663)', () => {
|
||
test('matches unchecked checklist item (- [ ] Phase 4-01:)', () => {
|
||
const content = [
|
||
'# Roadmap',
|
||
'- [ ] Phase 4-01: Qux',
|
||
].join('\n');
|
||
|
||
const notStarted = buildNotStartedPhaseVariants(content);
|
||
// phaseVariants() expands 4-01 into multiple forms; at minimum the raw form is present.
|
||
assert.ok(notStarted.has('4-01') || notStarted.has('04-01'),
|
||
'notStarted should contain a variant of 4-01');
|
||
});
|
||
|
||
test('does not pick up checked items', () => {
|
||
const content = [
|
||
'# Roadmap',
|
||
'- [x] Phase 5: Done',
|
||
].join('\n');
|
||
|
||
const notStarted = buildNotStartedPhaseVariants(content);
|
||
assert.strictEqual(notStarted.has('5'), false, 'completed phase should not be in notStarted');
|
||
});
|
||
});
|
||
|
||
// ─── Part B: ReDoS adversarial fixtures ──────────────────────────────────────
|
||
|
||
describe('buildRoadmapPhaseVariants — ReDoS adversarial fixture (#663)', () => {
|
||
// Pathological input: a heading line where the phase-id segment consists of
|
||
// many consecutive "-a" chunks with NO terminating colon. Under the old
|
||
// nested-quantifier pattern ([\w.-]*(?:-[\w.-]+)*\s*:) the engine must
|
||
// explore exponentially many ways to partition the "-a" repetitions before
|
||
// concluding there is no match. The fixed single-quantifier pattern
|
||
// ([\w.-]*\s*:) backtracks linearly. We assert that the malformed heading
|
||
// yields NO match (empty roadmapPhases Set) — the correct behavior when the
|
||
// terminating colon is absent. The { timeout: 5000 } backstop catches any
|
||
// regression that re-introduces a slow pattern.
|
||
test('malformed heading without colon yields no phase match (adversarial input)', { timeout: 5000 }, () => {
|
||
const pathological = '## Phase a' + '-a'.repeat(32) + ' ';
|
||
const { roadmapPhases } = buildRoadmapPhaseVariants(pathological);
|
||
assert.strictEqual(roadmapPhases.size, 0,
|
||
'a heading with no terminating colon should not match any phase');
|
||
});
|
||
});
|
||
|
||
describe('buildNotStartedPhaseVariants — ReDoS adversarial fixture (#663)', () => {
|
||
// Same analysis: the old uncheckedPattern used the same nested quantifier.
|
||
// A checklist-style line with many "-a" segments and no colon triggers the
|
||
// same catastrophic backtracking. Assert the correct structured result:
|
||
// the malformed line yields an empty notStarted Set.
|
||
test('malformed unchecked-item without terminator yields no phase match (adversarial input)', { timeout: 5000 }, () => {
|
||
// No trailing colon or whitespace: the regex terminator [:\s*] cannot match,
|
||
// so the engine must backtrack through all '-a' repetitions and conclude no
|
||
// match. Under the old nested-quantifier pattern this was exponential;
|
||
// under the fixed linear pattern it returns immediately with an empty Set.
|
||
const pathological = '- [ ] Phase a' + '-a'.repeat(32);
|
||
const notStarted = buildNotStartedPhaseVariants(pathological);
|
||
assert.strictEqual(notStarted.size, 0,
|
||
'an unchecked-item line with no terminating colon or space should not match any phase');
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/bug-892-validate-checklist-roadmap-phases.test.cjs — consolidation epic #1969 (B3 #1972)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:bug-892-validate-checklist-roadmap-phases (consolidation epic #1969 B3 #1972)", () => {
|
||
/**
|
||
* Regression test for #892: checklist-style roadmap phases (`- [x] **Phase NN: name**`)
|
||
* were silently skipped by `buildRoadmapPhaseVariants()`, causing W007 false positives
|
||
* on every on-disk phase dir when the project uses the checklist ROADMAP format.
|
||
*
|
||
* Covers:
|
||
* A. Unit-level: `buildRoadmapPhaseVariants()` in validate.cts recognises both
|
||
* checked (`- [x]`) and unchecked (`- [ ]`) checklist items.
|
||
* B. Integration: `validate health` emits NO W007 for a checklist-only roadmap
|
||
* whose phase dirs all appear in the checklist.
|
||
* C. Integration: `validate consistency` emits NO "exists on disk but not in
|
||
* ROADMAP.md" warning for the same checklist-only roadmap.
|
||
*
|
||
* Requirements: BUG-892
|
||
*/
|
||
'use strict';
|
||
|
||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||
const {
|
||
buildRoadmapPhaseVariants,
|
||
} = require('../gsd-core/bin/lib/validate.cjs');
|
||
|
||
// ─── A: Unit-level ────────────────────────────────────────────────────────────
|
||
|
||
describe('buildRoadmapPhaseVariants — checklist format support (#892)', () => {
|
||
test('matches checked checklist item `- [x] **Phase 01: name**`', () => {
|
||
const content = [
|
||
'# Roadmap',
|
||
'',
|
||
'- [x] **Phase 01: infrastructure-hardening**',
|
||
].join('\n');
|
||
|
||
const { roadmapPhases } = buildRoadmapPhaseVariants(content);
|
||
assert.ok(
|
||
roadmapPhases.has('01') || roadmapPhases.has('1'),
|
||
`roadmapPhases should contain a variant of "01", got: ${JSON.stringify([...roadmapPhases])}`
|
||
);
|
||
});
|
||
|
||
test('matches checked checklist item with uppercase X `- [X] **Phase 02: foo**`', () => {
|
||
const content = [
|
||
'# Roadmap',
|
||
'',
|
||
'- [X] **Phase 02: feature-work**',
|
||
].join('\n');
|
||
|
||
const { roadmapPhases } = buildRoadmapPhaseVariants(content);
|
||
assert.ok(
|
||
roadmapPhases.has('02') || roadmapPhases.has('2'),
|
||
`roadmapPhases should contain a variant of "02", got: ${JSON.stringify([...roadmapPhases])}`
|
||
);
|
||
});
|
||
|
||
test('matches unchecked checklist item `- [ ] **Phase 03: name**`', () => {
|
||
// unchecked items are also phases — they just have not been started
|
||
const content = [
|
||
'# Roadmap',
|
||
'',
|
||
'- [ ] **Phase 03: future-work**',
|
||
].join('\n');
|
||
|
||
const { roadmapPhases } = buildRoadmapPhaseVariants(content);
|
||
assert.ok(
|
||
roadmapPhases.has('03') || roadmapPhases.has('3'),
|
||
`roadmapPhases should contain a variant of "03", got: ${JSON.stringify([...roadmapPhases])}`
|
||
);
|
||
});
|
||
|
||
test('collects all phases from a pure checklist roadmap (no ## headings)', () => {
|
||
const content = [
|
||
'# Roadmap',
|
||
'',
|
||
'- [x] **Phase 01: alpha**',
|
||
'- [x] **Phase 02: beta**',
|
||
'- [ ] **Phase 03: gamma**',
|
||
].join('\n');
|
||
|
||
const { roadmapPhases } = buildRoadmapPhaseVariants(content);
|
||
const has = (id) => roadmapPhases.has(id) || roadmapPhases.has(id.replace(/^0+/, '')) || roadmapPhases.has(String(parseInt(id, 10)).padStart(2, '0'));
|
||
assert.ok(has('01'), 'should contain phase 01');
|
||
assert.ok(has('02'), 'should contain phase 02');
|
||
assert.ok(has('03'), 'should contain phase 03');
|
||
});
|
||
|
||
test('populates roadmapPhaseVariants with padding-normalised forms for checklist phases', () => {
|
||
const content = [
|
||
'# Roadmap',
|
||
'',
|
||
'- [x] **Phase 01: something**',
|
||
].join('\n');
|
||
|
||
const { roadmapPhaseVariants } = buildRoadmapPhaseVariants(content);
|
||
// phaseVariants() adds both '1' and '01' forms
|
||
assert.ok(
|
||
roadmapPhaseVariants.has('1') || roadmapPhaseVariants.has('01'),
|
||
`roadmapPhaseVariants should contain at least one padding form, got: ${JSON.stringify([...roadmapPhaseVariants])}`
|
||
);
|
||
});
|
||
|
||
test('mixed roadmap (headings + checklist) collects phases from both styles', () => {
|
||
const content = [
|
||
'# Roadmap',
|
||
'',
|
||
'## Phase 1: heading-style',
|
||
'',
|
||
'- [x] **Phase 02: checklist-style**',
|
||
].join('\n');
|
||
|
||
const { roadmapPhases } = buildRoadmapPhaseVariants(content);
|
||
assert.ok(
|
||
roadmapPhases.has('1') || roadmapPhases.has('01'),
|
||
'should contain heading-style phase 1'
|
||
);
|
||
assert.ok(
|
||
roadmapPhases.has('02') || roadmapPhases.has('2'),
|
||
'should contain checklist-style phase 02'
|
||
);
|
||
});
|
||
});
|
||
|
||
// ─── B: validate health — no W007 for checklist-only roadmaps ────────────────
|
||
|
||
describe('validate health — checklist-style roadmap phases must not emit W007 (#892)', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('no W007 when phase dirs match checked checklist entries in ROADMAP.md', () => {
|
||
// Write PROJECT.md, STATE.md, config.json, and a checklist-only ROADMAP.md
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'PROJECT.md'),
|
||
'# Project\n\n## What This Is\n\nTest.\n\n## Core Value\n\nValue.\n\n## Requirements\n\nRequirements.\n'
|
||
);
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
'# Session State\n\n## Current Position\n\nPhase 1 in progress.\n'
|
||
);
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'config.json'),
|
||
JSON.stringify({ model_profile: 'balanced', commit_docs: true }, null, 2)
|
||
);
|
||
|
||
// Checklist-only ROADMAP: no ## Phase headings, only checklist items
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||
[
|
||
'# Roadmap',
|
||
'',
|
||
'- [x] **Phase 01: infrastructure-hardening**',
|
||
'- [x] **Phase 02: feature-work**',
|
||
'',
|
||
].join('\n')
|
||
);
|
||
|
||
// Create matching phase directories on disk
|
||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-infrastructure-hardening'), { recursive: true });
|
||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-feature-work'), { recursive: true });
|
||
|
||
const result = runGsdTools('validate health', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
const w007s = output.warnings.filter((w) => w.code === 'W007');
|
||
assert.strictEqual(
|
||
w007s.length,
|
||
0,
|
||
`W007 must not fire for phases whose dirs are listed in a checklist-style ROADMAP.md, got: ${JSON.stringify(w007s)}`
|
||
);
|
||
});
|
||
|
||
test('W007 still fires when a phase dir is genuinely absent from a checklist roadmap', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'PROJECT.md'),
|
||
'# Project\n\n## What This Is\n\nTest.\n\n## Core Value\n\nValue.\n\n## Requirements\n\nRequirements.\n'
|
||
);
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
'# Session State\n\n## Current Position\n\nPhase 1 in progress.\n'
|
||
);
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'config.json'),
|
||
JSON.stringify({ model_profile: 'balanced', commit_docs: true }, null, 2)
|
||
);
|
||
|
||
// ROADMAP only lists phase 01, not phase 99
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||
[
|
||
'# Roadmap',
|
||
'',
|
||
'- [x] **Phase 01: known-phase**',
|
||
'',
|
||
].join('\n')
|
||
);
|
||
|
||
// Phase 01 dir is present but also an orphan phase 99 that is NOT in ROADMAP
|
||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-known-phase'), { recursive: true });
|
||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '99-orphan'), { recursive: true });
|
||
|
||
const result = runGsdTools('validate health', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
const w007s = output.warnings.filter((w) => w.code === 'W007');
|
||
assert.ok(
|
||
w007s.length > 0,
|
||
`W007 must still fire for a phase dir genuinely not listed in ROADMAP.md, got warnings: ${JSON.stringify(output.warnings)}`
|
||
);
|
||
// Should only flag phase 99, not phase 01
|
||
assert.ok(
|
||
w007s.some((w) => w.message.includes('99')),
|
||
`W007 should reference orphan phase 99, got: ${JSON.stringify(w007s)}`
|
||
);
|
||
assert.ok(
|
||
!w007s.some((w) => w.message.includes('01') || w.message.includes('1')),
|
||
`W007 must NOT flag phase 01 which is in the checklist, got: ${JSON.stringify(w007s)}`
|
||
);
|
||
});
|
||
});
|
||
|
||
// ─── C: validate consistency — no false positive for checklist-only roadmaps ─
|
||
|
||
describe('validate consistency — checklist-style roadmap phases must not emit false warnings (#892)', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('no "exists on disk but not in ROADMAP.md" warning for checklist-matched phases', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'PROJECT.md'),
|
||
'# Project\n\n## What This Is\n\nTest.\n\n## Core Value\n\nValue.\n\n## Requirements\n\nRequirements.\n'
|
||
);
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
'# Session State\n\n## Current Position\n\nPhase 1 in progress.\n'
|
||
);
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'config.json'),
|
||
JSON.stringify({ model_profile: 'balanced', commit_docs: true }, null, 2)
|
||
);
|
||
|
||
// Checklist-only ROADMAP
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||
[
|
||
'# Roadmap',
|
||
'',
|
||
'- [x] **Phase 01: infrastructure-hardening**',
|
||
'- [x] **Phase 02: feature-work**',
|
||
'',
|
||
].join('\n')
|
||
);
|
||
|
||
// Create matching phase directories
|
||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-infrastructure-hardening'), { recursive: true });
|
||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-feature-work'), { recursive: true });
|
||
|
||
const result = runGsdTools('validate consistency', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
// No "exists on disk but not in ROADMAP" warnings for checklist-listed phases
|
||
const diskNotInRoadmapWarnings = (output.warnings || []).filter(
|
||
(w) => typeof w === 'string'
|
||
? w.includes('exists on disk but not in ROADMAP')
|
||
: (w.message || '').includes('exists on disk but not in ROADMAP')
|
||
);
|
||
assert.strictEqual(
|
||
diskNotInRoadmapWarnings.length,
|
||
0,
|
||
`No "exists on disk but not in ROADMAP.md" warnings should fire for checklist-listed phases, got: ${JSON.stringify(diskNotInRoadmapWarnings)}`
|
||
);
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
// ── W024 (#2573): STATE.md commit-age freshness advisory ─────────────────────
|
||
//
|
||
// Advisory ONLY. It appends to warnings[] and must never change `status` or any
|
||
// existing count — promoting it to a gate is a separate, disclosed change.
|
||
//
|
||
// Goodhart guard (why the threshold is coarse and the wording is a proxy):
|
||
// `state_head` restamps on EVERY state write, so a low commits-behind count
|
||
// means "something wrote STATE recently", not "STATE's content is accurate".
|
||
// The number is a freshness proxy; the message must never assert drift.
|
||
describe('W024 — STATE.md commit-age freshness advisory (#2573)', () => {
|
||
const { after } = require('node:test');
|
||
const fs = require('node:fs');
|
||
const os = require('node:os');
|
||
const path = require('node:path');
|
||
const { runGsdTools, cleanup } = require('./helpers.cjs');
|
||
const { runGit, OUTCOME } = require('./helpers/process-seam.cjs');
|
||
const {
|
||
STATE_HEAD_ADVISORY_COMMITS,
|
||
} = require('../gsd-core/bin/lib/verify.cjs');
|
||
|
||
const dirs = [];
|
||
const track = (d) => { dirs.push(d); return d; };
|
||
after(() => { while (dirs.length) cleanup(dirs.pop()); });
|
||
|
||
// `runGit` (tests/helpers/process-seam.cjs) reports a failed spawn as DATA,
|
||
// never as a throw — by design, so retry-aware callers can inspect it. A
|
||
// fixture builder that ignores that return value swallows the failure and
|
||
// silently produces a WEAKER input (e.g. one fewer commit, a blank
|
||
// `state_head`) than what the test asked for: the exact "swallowed
|
||
// precondition laundered into a plausible downstream outcome" shape that
|
||
// eslint-rules/no-swallowed-precondition.cjs exists to catch, just in test
|
||
// code instead of source. `mustGit` closes that gap for this builder.
|
||
function mustGit(args, options) {
|
||
const r = runGit(args, options);
|
||
if (r.outcome !== OUTCOME.EXITED || r.exitCode !== 0) {
|
||
throw new Error(
|
||
`mustGit: \`git ${args.join(' ')}\` did not succeed ` +
|
||
`(outcome=${r.outcome}, exitCode=${r.exitCode}): ${r.stderr.trim()}`
|
||
);
|
||
}
|
||
return r;
|
||
}
|
||
|
||
function project({ commitsAhead, stateHead = 'BASE' }) {
|
||
const base = track(fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2573-h-')));
|
||
const planningDir = path.join(base, '.planning');
|
||
fs.mkdirSync(path.join(planningDir, 'phases'), { recursive: true });
|
||
fs.writeFileSync(
|
||
path.join(planningDir, 'PROJECT.md'),
|
||
'# Project\n\n## What This Is\nTest.\n\n## Core Value\nTest.\n\n## Requirements\nTest.\n',
|
||
);
|
||
fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify({ model_profile: 'balanced' }));
|
||
fs.writeFileSync(
|
||
path.join(planningDir, 'ROADMAP.md'),
|
||
'# Roadmap\n\n## Milestone v1.0\n\n### Phase 1: One\n**Goal:** g\n',
|
||
);
|
||
|
||
mustGit(['init', '-q'], { cwd: base });
|
||
mustGit(['config', 'user.email', 't@t.com'], { cwd: base });
|
||
mustGit(['config', 'user.name', 'T'], { cwd: base });
|
||
mustGit(['config', 'commit.gpgsign', 'false'], { cwd: base });
|
||
mustGit(['add', '-A'], { cwd: base });
|
||
mustGit(['commit', '-q', '-m', 'seed'], { cwd: base });
|
||
const seed = mustGit(['rev-parse', 'HEAD'], { cwd: base }).stdout.trim();
|
||
const head = seed;
|
||
assert.ok(head.length > 0, 'FIXTURE ERROR: `git rev-parse HEAD` for the seed commit returned empty');
|
||
|
||
fs.writeFileSync(
|
||
path.join(planningDir, 'STATE.md'),
|
||
[
|
||
'---',
|
||
'status: executing',
|
||
...(stateHead === null ? [] : [`state_head: ${stateHead === 'BASE' ? head : stateHead}`]),
|
||
'---',
|
||
'',
|
||
'# State',
|
||
'',
|
||
'**Current Phase:** 1',
|
||
'**Status:** In progress',
|
||
'',
|
||
].join('\n'),
|
||
);
|
||
|
||
for (let i = 0; i < commitsAhead; i++) {
|
||
fs.writeFileSync(path.join(base, `f${i}.txt`), `${i}\n`);
|
||
mustGit(['add', '-A'], { cwd: base });
|
||
mustGit(['commit', '-q', '-m', `c${i}`], { cwd: base });
|
||
}
|
||
|
||
// Precondition check: prove the fixture built what it claims rather than
|
||
// trusting the loop above ran to completion. A silently-failed `git
|
||
// commit` here previously dropped one commit off the count with no
|
||
// signal, which was enough to move `readStateHeadFreshness`
|
||
// (src/state.cts) across the W024 threshold and produce a false negative
|
||
// (#2573 flake). Fail as a FIXTURE error, not as the assertion under test.
|
||
const actualCommitsAhead = Number(
|
||
mustGit(['rev-list', '--count', `${seed}..HEAD`], { cwd: base }).stdout.trim()
|
||
);
|
||
assert.strictEqual(
|
||
actualCommitsAhead,
|
||
commitsAhead,
|
||
`FIXTURE ERROR: requested commitsAhead=${commitsAhead} but ` +
|
||
`\`git rev-list --count ${seed}..HEAD\` reports ${actualCommitsAhead}`
|
||
);
|
||
|
||
return base;
|
||
}
|
||
|
||
function health(dir) {
|
||
const r = runGsdTools(['validate', 'health', '--json'], dir);
|
||
assert.strictEqual(r.success, true, `unexpected failure: ${r.error}`);
|
||
return JSON.parse(r.output);
|
||
}
|
||
|
||
const w024 = (d) => (d.warnings ?? []).filter((w) => w.code === 'W024');
|
||
|
||
test('exports a named threshold constant rather than a bare magic number', () => {
|
||
assert.strictEqual(typeof STATE_HEAD_ADVISORY_COMMITS, 'number');
|
||
assert.ok(STATE_HEAD_ADVISORY_COMMITS > 0);
|
||
});
|
||
|
||
test('does NOT fire when STATE.md was written at HEAD', () => {
|
||
const data = health(project({ commitsAhead: 0 }));
|
||
const w024 = (data.warnings ?? []).filter((w) => w.code === 'W024');
|
||
assert.strictEqual(w024.length, 0, `expected no W024, got ${JSON.stringify(w024)}`);
|
||
});
|
||
|
||
test('boundary: silent at threshold-1, fires at threshold+1', () => {
|
||
// The 0/1/20 cases alone leave the actual boundary untested — 1 is a
|
||
// trivial-fit, not an edge. These two pin the comparison operator.
|
||
const below = health(project({ commitsAhead: STATE_HEAD_ADVISORY_COMMITS - 1 }));
|
||
assert.strictEqual((below.warnings ?? []).filter((w) => w.code === 'W024').length, 0,
|
||
`must stay silent at ${STATE_HEAD_ADVISORY_COMMITS - 1} commits`);
|
||
const above = health(project({ commitsAhead: STATE_HEAD_ADVISORY_COMMITS + 1 }));
|
||
assert.strictEqual((above.warnings ?? []).filter((w) => w.code === 'W024').length, 1,
|
||
`must fire at ${STATE_HEAD_ADVISORY_COMMITS + 1} commits`);
|
||
});
|
||
|
||
test('does NOT fire below the threshold (a healthy project stays quiet)', () => {
|
||
// Hyrum guard: firing on every ordinary project would change health's
|
||
// observable "clean" state and make anything gating on clean-health noisy.
|
||
const data = health(project({ commitsAhead: 1 }));
|
||
const w024 = (data.warnings ?? []).filter((w) => w.code === 'W024');
|
||
assert.strictEqual(w024.length, 0, `expected no W024 at 1 commit, got ${JSON.stringify(w024)}`);
|
||
});
|
||
|
||
test('fires at/above the threshold, states the count, and stays a proxy (never asserts drift)', () => {
|
||
const data = health(project({ commitsAhead: STATE_HEAD_ADVISORY_COMMITS }));
|
||
const w024 = (data.warnings ?? []).filter((w) => w.code === 'W024');
|
||
assert.strictEqual(w024.length, 1, `expected exactly one W024, got ${JSON.stringify(data.warnings)}`);
|
||
const msg = String(w024[0].message);
|
||
assert.ok(msg.includes(String(STATE_HEAD_ADVISORY_COMMITS)),
|
||
`W024 must state the commit count, got: ${msg}`);
|
||
assert.ok(/approximate/i.test(msg),
|
||
`W024 must frame the signal as approximate (freshness proxy), got: ${msg}`);
|
||
assert.ok(!/\bdrift(ed)?\b|\bis wrong\b|\bstale content\b/i.test(msg),
|
||
`W024 must NOT assert drift — it is a proxy, not a measurement. Got: ${msg}`);
|
||
});
|
||
|
||
test('is advisory only — lands in warnings[], never errors[] or the repair set', () => {
|
||
// NOT "stale.status === fresh.status": these fixtures already carry W006
|
||
// ("Phase in ROADMAP but no directory"), so both sides are `degraded`
|
||
// regardless of W024 and that assertion can never fail. Health derives
|
||
// status from warnings by design (warnings -> degraded), so the real
|
||
// invariant is that W024 is a WARNING and never escalates.
|
||
const data = health(project({ commitsAhead: STATE_HEAD_ADVISORY_COMMITS }));
|
||
assert.strictEqual(w024(data).length, 1, 'precondition: W024 fired');
|
||
assert.ok(
|
||
!(data.errors ?? []).some((e) => e.code === 'W024'),
|
||
`W024 must never appear in errors[]: ${JSON.stringify(data.errors)}`,
|
||
);
|
||
assert.notStrictEqual(data.status, 'broken',
|
||
'an advisory must never break health');
|
||
assert.ok(!w024(data)[0].repairable,
|
||
'W024 is diagnostic, not auto-repairable — it must not enter the repair set');
|
||
});
|
||
|
||
test('absent state_head → no W024 (unknown is not a finding)', () => {
|
||
const data = health(project({ commitsAhead: 5, stateHead: null }));
|
||
const w024 = (data.warnings ?? []).filter((w) => w.code === 'W024');
|
||
assert.strictEqual(w024.length, 0, `unknown must stay silent, got ${JSON.stringify(w024)}`);
|
||
});
|
||
});
|