* test(#3884): failing-first coverage for strict argv and absence-signalling --pick ADR-3473 §8.4 says failure is a value. Three families currently encode failure as success, and this commit pins each one RED before the fix lands. Measured on this tree, 2026-08-26: gsd-tools generate-slug "test" --pick nonexistent -> empty stdout, exit 0 (#3365) gsd-tools audit-open --pick nonexistent_field -> dumps the entire human-readable audit report, exit 0 gsd-tools generate-slug "Hello World" --raw --pick bogus -> prints "hello-world", another field's value, exit 0 gsd-tools query state.planned-phase 3 (positional, no --phase) -> exit 0; STATE.md's "Phase: 2 of 5 (Widget Support)" is overwritten to "Phase: null - READY TO EXECUTE" and the frontmatter gains a corrupted current_phase_name (#3358) tests/pick-flag.test.cjs:27 previously asserted the #3365 defect as the contract ("returns empty string for missing field", success === true). That assertion is replaced by the required behavior rather than deleted. The new parseNamedArgs block calls the spec-object signature that does not exist yet, so it fails today by construction. The 11 existing behavior-lock tests are left untouched here; they are corrected in the implementation commit. C1/C4 assert at the consumer's output - STATE.md's bytes - per ADR-3180 Decision 4(b). A unit assertion on the parser would have passed throughout this defect's life. Design: .gsd/phase/feat-3884-failure-is-a-value/40-design.md Test matrix: .gsd/phase/feat-3884-failure-is-a-value/50-test-matrix.md Refs #3884 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * enhance(#3884): failure is a value — strict argv, and --pick that signals absence Implements ADR-3473 §8.4. Absence, emptiness and failure stop being interchangeable ways to say "I could not answer". parseNamedArgs (src/command-arg-projection.cts) Takes a spec object with a REQUIRED `positionals: number | 'rest'` and returns the hub's Result shape instead of a bare Record. Declaring the positional arity is what makes #3358's call site unrepresentable rather than merely detectable: an unrecognized flag or a token past the declared boundary is now InvalidArgs, naming the offending token and listing the accepted flags. The legacy positional-array call shape throws a TypeError — an internal invariant violation per ADR-3473 Decision 2, so a stale hand-written .cjs call site fails loudly instead of destructuring undefined off a Result. parseNamedArgsOrExit projects a failure onto the caller's error(); it is a projection over the one parser, not a second parser. Measured before, against a STATE.md with a populated phase-2 block: query state.planned-phase 3 (positional, no --phase) -> exit 0; "Phase: 2 of 5 (Widget Support)" overwritten to "Phase: null - READY TO EXECUTE", frontmatter gains a corrupted current_phase_name After: exit 1, `unexpected positional argument "3"`, STATE.md byte-identical. The flag form is unchanged and still updates STATE.md. --pick <field> (gsd-core/bin/gsd-tools.cjs) extractField returns {found,value}, and the pick block no longer shares one catch between "output was not JSON" and "field was absent". An absent field exits 1 with pick_field_absent, naming the field and the keys that do exist; non-JSON output exits 1 with pick_output_not_json instead of dumping the command's entire output. A field that is PRESENT with value null, '', 0 or false still prints at exit 0 — that is an answer, not a failure, and it is what keeps `--pick count` printing 0 on a fresh project. Measured before: `audit-open --pick nonexistent_field` printed the whole human-readable audit report at exit 0, and `generate-slug X --raw --pick bogus` printed "hello-world" — a different field's value, confidently, at exit 0. ADR-3409 Decision 7 explicitly deferred this contract fix to #3473; this is it. The sub-issue's "returns 0 when the count is zero OR absent" wording is superseded by the ADR rule it implements: zero prints 0, absence exits non-zero. Defaulting absence to 0 would demote "could not answer" to "the answer is zero" — the hazard docs/how-to/resolve-unreachable-guard-findings.md already warns against. Guard ledger (ADR-3473 Decision 6) scripts/lint-unreachable-guard-drift.cjs Detector A is RETIRED. Its premise — that a `--pick ... || echo` arm can never fire — is now false, so the shape it forbade is the correct idiom and keeping it would forbid the fix. Detector B (glob-consuming cat/ls, a nullglob mechanism this change does not touch) is retained in full, as are the shared scanner, the escape-marker parser and the baseline. Net: -1 detector, 0 added. The file is not deleted. Call-site audit 45 prompt-layer --pick invocations, every one a plain X=$(...) assignment — none in an if test, && chain, or a pipeline whose status is consumed, and no shell block in workflows/commands/agents/references sets -e. Of the 13 (command, field) pairs the prompt layer reads, 10 are always present; the 3 sometimes-absent ones each sit behind a prior found/existence check. No ADR-3409-class "field the command never produces" remains. Design: .gsd/phase/feat-3884-failure-is-a-value/40-design.md Test matrix: .gsd/phase/feat-3884-failure-is-a-value/50-test-matrix.md Refs #3884 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3884): escape untrusted tokens in diagnostics, and cover five unpinned rows Two review findings, both fixed here rather than recorded as limits. 1. A newline in an untrusted token forged a second stderr line. Before, plain-text mode: $ gsd-tools query state.planned-phase $'foo\nError: forged second line' Error: unexpected positional argument "foo Error: forged second line" After: Error: unexpected positional argument "foo\nError: forged second line" --json-errors mode was never affected — io.error runs that payload through JSON.stringify. Plain-text mode writes 'Error: ' + message verbatim, and the three new InvalidArgs reasons plus the two new --pick diagnostics all interpolate a token that comes straight from argv. Fixed with ONE shared helper, formatDiagnosticToken (src/io.cts), applied at every interpolation site — not a copy per site. It is deliberately NOT applied inside error() itself: several callers in this tree emit intentional multi-line diagnostics, and escaping newlines there would mangle them. The available-top-level-keys list needed the same treatment for a reason the review did not anticipate: `frontmatter get <file>` reads an ARBITRARY user document and echoes that document's own keys into the diagnostic. Verified reachable — a frontmatter key containing a newline reaches the key list — so formatKeyForDiagnosticList is guarding a live path, not a hypothetical one. Ordinary keys still render plain and unquoted; a fix that merely dropped the key would also have passed a "one line" assertion, so the test pins the escaped key's presence too. 2. Five behavior-table rows were implemented but nothing pinned them: B7 a dotted path that dies partway B9 bracket syntax on a non-array B10 a negative array index, in and out of range B14 a JSON root that is not an object B17 an @file: payload over 50KB B17 is the load-bearing one. output() writes @file:<path> instead of inline JSON past 50000 characters, and --pick resolves that BEFORE parsing; with no test, a future reordering of those two steps turns every large result into a false pick_output_not_json. The fixture seeds 1200 phase directories and measures the payload at 62474 characters, asserting the spill actually happened rather than assuming it. Refs #3884 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3884): correct the strict-argv surface against a full verification run The first full run came back with 90 failures across 12 files, none in the new tests. They were the argv surface telling me what it actually is. Ten root causes; each classified before anything was changed. I over-implemented, and that is reverted. ADR-3473 §8.4 says parseNamedArgs rejects "unrecognized and positional tokens". It says nothing about a value flag whose value is missing. Making that an error was my design decision, not the rule, and it broke a deliberately recorded contract: `--prd` with no value resolving to null (tests/init.test.cjs emptyPrdValueIsFalsyAndTreatedAsAbsent, row B5; tests/section-manifest-init-facts.test.cjs "flag-shaped value"). The "requires a value" branch is deleted outright rather than kept behind an option — an unused strictness mode is speculative generality. Unknown-flag and unexpected-positional rejection, which is what §8.4 actually mandates, is unchanged. --wave needed a third flag kind the original design did not anticipate. `--wave N` is documented (commands/gsd/execute-phase.md:4,48) and the shipped workflow reconstructs and passes it (execute-phase.md:84), while #2932 records token-PRESENCE semantics: the CLI cares only that the flag appeared, and the value belongs to the workflow layer. That is neither a boolean flag nor a value flag, so `optionalValueFlags` now exists — presence-only in `data`, and the validation cursor consumes a following non-flag token so it is not reported as a stray positional. Every other declared boolean flag was checked against every argument-hint and prose usage in commands/, workflows/, agents/ and docs/; `--wave` is the only one of this shape. Five tests were pinning forms that never worked. tests/adr857-core-without-capabilities.test.cjs passed `init plan-phase --phase 01-stub`, but the documented form is positional (docs/CLI-TOOLS.md:776) and the handler reads args[2] — which for that form is the literal string "--phase". Measured on the pre-fix build against a real .planning/phases/01-stub/ directory: init plan-phase 01-stub -> phase_found=true init plan-phase --phase 01-stub -> phase_found=false The test asserted only exit 0 and key presence, so it had been green while proving nothing about phase resolution. Corrected to the documented form and strengthened to assert phase_found === true. Same class in state.test.cjs (`--plan-count`, a flag that does not exist; the real one is `--plans`), milestone-archive.test.cjs (`init new-milestone --json`, silently ignored), and concurrency-safety.test.cjs (a bare positional field name whose OR-assertion passed because a whole-document dump happens to contain the substring it looked for). Six handlers had no argv validation at all — the same #3358 shape this phase exists to close, found while fixing the rest: init verify-work / phase-op / review / todos / remove-workspace read args[2] with nothing checking the rest, and validate health read --repair/--backfill through a bare args.includes() scan that bypassed the parser entirely. All now go through the seam, so the flag has one owner. tests/init-debug.test.cjs rows C4/C5 asserted that an unrecognized flag must NOT fail. That is the behavior §8.4 removes, and Decision 8 says a caller's local expectation does not override §8, so they are inverted and renamed — a test still called "ignores an unrecognized flag" while asserting rejection would be its own defect. Row C6's point is its PWNED canary; that assertion is kept verbatim and only its exit-status expectation changed, because the hostile token is now rejected rather than absorbed. The blast-radius estimate in 40-design.md is corrected rather than quietly left wrong. get_impact reported MEDIUM / 8 symbols upstream, and that was accurate for what the graph can see — parseNamedArgs's callers. It cannot see that those callers' handlers accept argv shapes wider than the code reading args[2] suggests, which is where the real surface was. Refs #3884 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3884): withdraw the validate-health tightening, finish the A2/A3 revert Second full run: 46 failures, down from 90. Four causes, two of them mine. Reverted `validate health` entirely — it was scope creep, and it broke a real flag. ~30 of the 46 read `unknown flag "--json"; accepted: --repair, --backfill`. The previous commit routed `validate health` through the parser on the reasoning that a flag should have one owner. That was wrong twice over: §8.4 names parseNamedArgs and count queries, and `validate health` was never a parseNamedArgs call site — it read its flags, just not through the parser, so it had no silent-drop defect to fix. Tightening it omitted `--json`, which the health-diagnostic suites use heavily. The handler is now byte-for-behaviour back to its pre-branch form. `validate context` stays converted: it genuinely was a call site, and its `--json` is now declared rather than read by a second `args.includes` scan. The five handlers that had NO validation at all — init verify-work / phase-op / review / todos / remove-workspace — stay fixed. Those read args[2] with nothing checking the rest, which is the #3358 shape this phase owns. Finished the A2/A3 revert. Three tests still encoded the deleted "a value flag with a missing value is an error" rule, including one added by the previous commit for that rule. All three now assert the reverted null contract, and the ones whose titles said "rejected" are renamed — a test named for a contract it no longer asserts is its own defect. `--wave=` and `--wave --weird` are correctly rejected. Neither is documented in commands/gsd/execute-phase.md, gsd-core/workflows/execute-phase.md or docs/, and neither is emitted by the shipped prompt layer, so both are unrecognized tokens that §8.4 mandates rejecting. `doesNotConsumeFollowingFlagAsWaveValue` keeps the property it exists for — asserted directly now, at the parser, that `--wave` does not swallow a following flag as its value — and only its exit-status expectation changed. A contradiction inside this branch, surfaced by the audit and resolved the safe way. Two pre-existing #3573 tests call `state begin-phase '2'` and `state planned-phase '2'` with a bare positional, relying on the old permissive parser to ignore it. This branch's own #3358 regression test requires that exact argv to be REJECTED. The two are mutually exclusive. Widening the router to accept a bare positional — mirroring complete-phase — would have silently re-opened #3358, and was verified to do exactly that: with the widened router, `query state.planned-phase 3` returned exit 0 and wrote current_phase_name again. It is reverted. docs/CLI-TOOLS.md:116 and docs/COMMANDS.md:2192 document only the `--phase N` form for both verbs, so the two #3573 tests move to it. Their assertions were never about the call shape — only that total_phases survives the resync — and both still pass. complete-phase is untouched: its bare positional IS documented, and it keeps the dynamic boundary and the negative-space note that record why. The audit that produced this is in the PR body: for every handler whose declaration changed, the flags it reads anywhere in its body, the flags the shipped surface documents, and the shapes the suite passes, compared. The `--json` miss was a pattern, not an accident — declaring a handler's flags from its parseNamedArgs call alone misses whatever it reads elsewhere. Refs #3884 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#3884): backfill the changeset PR number Refs #3884 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
4586 lines
217 KiB
JavaScript
4586 lines
217 KiB
JavaScript
/**
|
|
* GSD Tools Tests - Init
|
|
*/
|
|
|
|
const { test, describe, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { spawnSync } = require('node:child_process');
|
|
const { runGsdTools, cleanup, absPlanningPath, TOOLS_PATH, parseFrontmatter } = require('./helpers.cjs');
|
|
const { createFixture, seedPhase } = require('./fixtures/index.cjs');
|
|
const { createTempProject, createTempDir } = require('./helpers.cjs');
|
|
const { executionContextRefs } = require('../scripts/command-contract-helpers.cjs');
|
|
const { escapeRegex } = require('../gsd-core/bin/lib/pattern.cjs');
|
|
|
|
/**
|
|
* #3188: write the canonical flat planning docs so an init-query "present" test
|
|
* actually has the file it asserts. The emitter now returns null for
|
|
* state_path / roadmap_path / requirements_path when the file is absent; tests
|
|
* that exercise the present-case must therefore create the file. Phase
|
|
* resolution is directory-based (findPhaseInternal) and unaffected by these.
|
|
*/
|
|
function writePlanningDocs(tmpDir, { state = true, roadmap = true, requirements = true } = {}) {
|
|
const planning = path.join(tmpDir, '.planning');
|
|
fs.mkdirSync(planning, { recursive: true });
|
|
if (state) fs.writeFileSync(path.join(planning, 'STATE.md'), '# State\n');
|
|
if (roadmap) fs.writeFileSync(path.join(planning, 'ROADMAP.md'), '# Roadmap\n');
|
|
if (requirements) fs.writeFileSync(path.join(planning, 'REQUIREMENTS.md'), '# Requirements\n');
|
|
}
|
|
|
|
describe('init commands', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
// #2376 macOS fix: realpath the fixture root so absolute path-field
|
|
// assertions (absPlanningPath comparisons below) match the code's
|
|
// process.cwd()-anchored output — macOS's tmpdir is a symlink
|
|
// (/var/... -> /private/var/...) that a spawned child resolves via
|
|
// realpath but `createFixture()` does not. No-op on Linux (no symlink).
|
|
tmpDir = fs.realpathSync(createFixture());
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('init execute-phase returns file paths', () => {
|
|
seedPhase(tmpDir, '03-api', {
|
|
'03-01-PLAN.md': '# Plan',
|
|
});
|
|
// #3188: these are present-case assertions — the docs must exist on disk
|
|
// or the emitter now (correctly) returns null for the *_path fields.
|
|
writePlanningDocs(tmpDir);
|
|
|
|
const result = runGsdTools('init execute-phase 03', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.state_path, absPlanningPath(tmpDir, 'STATE.md'));
|
|
assert.strictEqual(output.roadmap_path, absPlanningPath(tmpDir, 'ROADMAP.md'));
|
|
assert.strictEqual(output.config_path, absPlanningPath(tmpDir, 'config.json'));
|
|
// #2376: execute-phase.md's verify_phase_goal step reads this instead of
|
|
// hardcoding '.planning/REQUIREMENTS.md' into the gsd-verifier spawn prompt.
|
|
assert.strictEqual(output.requirements_path, absPlanningPath(tmpDir, 'REQUIREMENTS.md'));
|
|
});
|
|
|
|
test('init execute-phase respects model_overrides for executor_model', () => {
|
|
seedPhase(tmpDir, '01-foundation', {
|
|
'01-01-PLAN.md': '# Plan',
|
|
});
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({
|
|
model_profile: 'balanced',
|
|
model_overrides: { 'gsd-executor': 'openai/o4-mini' },
|
|
}));
|
|
|
|
const result = runGsdTools('init execute-phase 1 --raw', tmpDir, { HOME: tmpDir });
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.executor_model, 'openai/o4-mini',
|
|
'model_overrides["gsd-executor"] must take precedence over profile');
|
|
});
|
|
|
|
test('init execute-phase respects model_overrides when resolve_model_ids is omit', () => {
|
|
seedPhase(tmpDir, '01-foundation', {
|
|
'01-01-PLAN.md': '# Plan',
|
|
});
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({
|
|
resolve_model_ids: 'omit',
|
|
model_overrides: { 'gsd-executor': 'openai/o4-mini' },
|
|
}));
|
|
|
|
const result = runGsdTools('init execute-phase 1 --raw', tmpDir, { HOME: tmpDir });
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.executor_model, 'openai/o4-mini',
|
|
'model_overrides must take precedence even when resolve_model_ids is omit');
|
|
});
|
|
|
|
test('init plan-phase returns file paths', () => {
|
|
seedPhase(tmpDir, '03-api', {
|
|
'03-CONTEXT.md': '# Phase Context',
|
|
'03-RESEARCH.md': '# Research Findings',
|
|
'03-VERIFICATION.md': '# Verification',
|
|
'03-UAT.md': '# UAT',
|
|
});
|
|
// #3188: present-case assertions — create the planning docs the emitter keys on.
|
|
writePlanningDocs(tmpDir);
|
|
|
|
const result = runGsdTools('init plan-phase 03', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.state_path, absPlanningPath(tmpDir, 'STATE.md'));
|
|
assert.strictEqual(output.roadmap_path, absPlanningPath(tmpDir, 'ROADMAP.md'));
|
|
assert.strictEqual(output.requirements_path, absPlanningPath(tmpDir, 'REQUIREMENTS.md'));
|
|
assert.strictEqual(output.context_path, absPlanningPath(tmpDir, 'phases', '03-api', '03-CONTEXT.md'));
|
|
assert.strictEqual(output.research_path, absPlanningPath(tmpDir, 'phases', '03-api', '03-RESEARCH.md'));
|
|
assert.strictEqual(output.verification_path, absPlanningPath(tmpDir, 'phases', '03-api', '03-VERIFICATION.md'));
|
|
assert.strictEqual(output.uat_path, absPlanningPath(tmpDir, 'phases', '03-api', '03-UAT.md'));
|
|
});
|
|
|
|
test('#3511-class: init manager has_context/has_research ignore another phase\'s misplaced artifact', () => {
|
|
writePlanningDocs(tmpDir);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
[
|
|
'# Roadmap',
|
|
'',
|
|
'## Progress',
|
|
'',
|
|
'- [ ] **Phase 1: Setup**',
|
|
'- [ ] **Phase 2: API**',
|
|
'',
|
|
'### Phase 1: Setup',
|
|
'',
|
|
'**Goal:** Build the foundation.',
|
|
'',
|
|
'### Phase 2: API',
|
|
'',
|
|
'**Goal:** Build the API.',
|
|
'',
|
|
].join('\n'),
|
|
);
|
|
|
|
seedPhase(tmpDir, '01-setup', {});
|
|
// Phase 02's directory holds ONLY a stray artifact whose filename token
|
|
// ("01-") belongs to phase 01, not to this directory's own phase (02).
|
|
seedPhase(tmpDir, '02-api', {
|
|
'01-RESEARCH.md': '# Research for phase 01',
|
|
'01-CONTEXT.md': '# Context for phase 01',
|
|
});
|
|
|
|
const result = runGsdTools('init manager', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
const p2 = output.phases.find((p) => p.number === '2');
|
|
assert.strictEqual(p2.has_research, false,
|
|
'phase 2 must not report has_research from a file that belongs to phase 1');
|
|
assert.strictEqual(p2.has_context, false,
|
|
'phase 2 must not report has_context from a file that belongs to phase 1');
|
|
});
|
|
|
|
test('#3511-class: init verify-work ui_phase_active ignores another phase\'s misplaced UI-SPEC file', () => {
|
|
writePlanningDocs(tmpDir);
|
|
// ui_phase_active is `hasActiveUiStep || hasUiSpecFile` (detectUiPhaseActive,
|
|
// src/init.cts) — the `ui` capability's `workflow.ui_phase` config key
|
|
// defaults to `true` (capabilities/ui/capability.json), which alone would
|
|
// make `hasActiveUiStep` (and therefore the whole OR) true regardless of
|
|
// which file the phase directory holds. Disabling it here isolates the
|
|
// signal this test actually exercises: the misplaced-file half of the OR.
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({ workflow: { ui_phase: false } }),
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
[
|
|
'# Roadmap',
|
|
'',
|
|
'### Phase 1: Setup',
|
|
'',
|
|
'**Goal:** Build the foundation.',
|
|
'',
|
|
'### Phase 2: API',
|
|
'',
|
|
'**Goal:** Build the API.',
|
|
'',
|
|
].join('\n'),
|
|
);
|
|
|
|
seedPhase(tmpDir, '01-setup', {});
|
|
// Phase 02's directory holds ONLY a stray artifact whose filename token
|
|
// ("01-") belongs to phase 01, not to this directory's own phase (02).
|
|
seedPhase(tmpDir, '02-api', {
|
|
'01-UI-SPEC.md': '# UI Spec for phase 01',
|
|
});
|
|
|
|
const result = runGsdTools('init verify-work 2', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.ui_phase_active, false,
|
|
'phase 2 must not report ui_phase_active from a UI-SPEC file that belongs to phase 1');
|
|
});
|
|
|
|
// #3473 F2 (companion to #3357): init plan-phase's verification_path
|
|
// projector now resolves via the shared resolveVerificationFile resolver
|
|
// instead of a hand-rolled `.find()` over unsorted readdir() order. The
|
|
// canonical report must win over an ad-hoc -CORRECTION- worksheet
|
|
// deterministically, regardless of directory-listing order.
|
|
test('#3473 F2: init plan-phase resolves the canonical report over a -CORRECTION- worksheet', () => {
|
|
seedPhase(tmpDir, '03-api', {
|
|
'03-CORRECTION-VERIFICATION.md': '# Ad-hoc correction worksheet',
|
|
'03-VERIFICATION.md': '# Verification',
|
|
});
|
|
writePlanningDocs(tmpDir);
|
|
|
|
const result = runGsdTools('init plan-phase 03', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(
|
|
output.verification_path,
|
|
absPlanningPath(tmpDir, 'phases', '03-api', '03-VERIFICATION.md'),
|
|
'the canonical 03-VERIFICATION.md must win over the CORRECTION worksheet',
|
|
);
|
|
});
|
|
|
|
// #3518: init plan-phase's uat_path projector must resolve via the shared
|
|
// resolveUatFile resolver (phase-pinned, deterministic) instead of a
|
|
// hand-rolled `.find()` over unsorted readdir() order. A stray cross-phase
|
|
// UAT artifact must never become THIS phase's uat_path, on any filesystem.
|
|
// The stray is listed first in the fixture (creation-order filesystems) AND
|
|
// sorts before the phase's own file (lexicographic-order filesystems), so
|
|
// the pre-fix readdir pick loses on both ordering families.
|
|
test('#3518: init plan-phase uat_path is phase-pinned — a stray cross-phase -UAT.md must not win', () => {
|
|
seedPhase(tmpDir, '03-api', {
|
|
'02-UAT.md': '# Stray cross-phase UAT artifact (belongs to phase 02)',
|
|
'03-UAT.md': '# UAT',
|
|
});
|
|
writePlanningDocs(tmpDir);
|
|
|
|
const result = runGsdTools('init plan-phase 03', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(
|
|
output.uat_path,
|
|
absPlanningPath(tmpDir, 'phases', '03-api', '03-UAT.md'),
|
|
'the phase\'s own 03-UAT.md must win over the stray cross-phase 02-UAT.md',
|
|
);
|
|
});
|
|
|
|
// #2056: normalizePhaseName() strips ANY [A-Z][A-Z0-9_]*- prefix as a project
|
|
// code, so a foreign-prefixed workstream/task id like "MEM-01" collapsed to
|
|
// "01" and resolved to the unrelated numeric Phase 01. init plan-phase must
|
|
// require exact prefixed evidence (a phase dir/roadmap entry literally
|
|
// carrying the foreign prefix) before accepting a numeric-fallback match.
|
|
test('#2056 — init plan-phase does not collapse foreign-prefixed task IDs into numeric phases', () => {
|
|
seedPhase(tmpDir, '01-stable-baseline-on-main', {
|
|
'01-CONTEXT.md': '# Phase Context',
|
|
});
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
'# Roadmap\n\n### Phase 1: Stable Baseline On Main\n**Goal:** Establish baseline\n**Plans:** 1 plan\n',
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({ project_code: 'LKML' }, null, 2),
|
|
);
|
|
|
|
const result = runGsdTools('init plan-phase MEM-01', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_found, false, 'MEM-01 must NOT resolve to numeric Phase 01');
|
|
assert.strictEqual(output.phase_dir, null);
|
|
assert.strictEqual(output.phase_number, null);
|
|
});
|
|
|
|
// #2056 companion: the guard must not reject the configured project_code's
|
|
// OWN prefixed phases — LKML-01 (project_code = LKML) must still resolve.
|
|
test('#2056 — init plan-phase still resolves configured project-code-prefixed phases', () => {
|
|
seedPhase(tmpDir, 'LKML-01-stable-baseline-on-main', {
|
|
'LKML-01-CONTEXT.md': '# Phase Context',
|
|
});
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({ project_code: 'LKML' }, null, 2),
|
|
);
|
|
|
|
const result = runGsdTools('init plan-phase LKML-01', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_found, true, 'LKML-01 (own project code) must resolve');
|
|
assert.strictEqual(output.phase_dir, absPlanningPath(tmpDir, 'phases', 'LKML-01-stable-baseline-on-main'));
|
|
assert.strictEqual(output.phase_number, 'LKML-01');
|
|
});
|
|
|
|
// #2056 accept-branch: a foreign-prefixed query MUST still resolve when a phase
|
|
// directory literally carries that prefix (e.g. a real MEM-01-* workstream
|
|
// phase). Proves the guard accepts exact-prefixed evidence, not just rejects.
|
|
test('#2056 — init plan-phase resolves a real foreign-prefixed phase dir', () => {
|
|
seedPhase(tmpDir, 'MEM-01-integration', {
|
|
'MEM-01-CONTEXT.md': '# Phase Context',
|
|
});
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({ project_code: 'LKML' }, null, 2),
|
|
);
|
|
|
|
const result = runGsdTools('init plan-phase MEM-01', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_found, true, 'a real MEM-01-* dir must resolve under its own prefix');
|
|
assert.strictEqual(output.phase_dir, absPlanningPath(tmpDir, 'phases', 'MEM-01-integration'));
|
|
assert.strictEqual(output.phase_number, 'MEM-01');
|
|
});
|
|
|
|
// #2056 edge: with NO project_code configured, any prefixed query is foreign
|
|
// and must NOT collapse to a numeric phase. Pins the strict default.
|
|
test('#2056 — init plan-phase treats prefixed queries as foreign when no project_code is configured', () => {
|
|
seedPhase(tmpDir, '01-stable-baseline-on-main', {
|
|
'01-CONTEXT.md': '# Phase Context',
|
|
});
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({}, null, 2),
|
|
);
|
|
|
|
const result = runGsdTools('init plan-phase MEM-01', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_found, false, 'with no project_code, MEM-01 must not resolve to numeric Phase 01');
|
|
assert.strictEqual(output.phase_number, null);
|
|
});
|
|
|
|
// #2104: the #2056 guard must also cover init execute-phase, verify-work,
|
|
// and phase-op — all three had unguarded findPhaseInternal/getRoadmapPhaseInternal
|
|
// calls that collapsed foreign prefixes (MEM-01 → 01) to numeric phases.
|
|
test('#2104 — init execute-phase does not collapse foreign-prefixed task IDs', () => {
|
|
seedPhase(tmpDir, '01-stable-baseline-on-main', {
|
|
'01-CONTEXT.md': '# Phase Context',
|
|
});
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
'# Roadmap\n\n### Phase 1: Stable Baseline On Main\n**Goal:** Establish baseline\n**Plans:** 1 plan\n',
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({ project_code: 'LKML' }, null, 2),
|
|
);
|
|
|
|
const result = runGsdTools('init execute-phase MEM-01', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_found, false, 'MEM-01 must NOT resolve to numeric Phase 01');
|
|
assert.strictEqual(output.phase_number, null);
|
|
});
|
|
|
|
test('#2104 — init verify-work does not collapse foreign-prefixed task IDs', () => {
|
|
seedPhase(tmpDir, '01-stable-baseline-on-main', {
|
|
'01-CONTEXT.md': '# Phase Context',
|
|
});
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
'# Roadmap\n\n### Phase 1: Stable Baseline On Main\n**Goal:** Establish baseline\n**Plans:** 1 plan\n',
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({ project_code: 'LKML' }, null, 2),
|
|
);
|
|
|
|
const result = runGsdTools('init verify-work MEM-01', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_found, false, 'MEM-01 must NOT resolve to numeric Phase 01');
|
|
});
|
|
|
|
// #2104 accept-branch: a real foreign-prefixed phase dir MUST still resolve
|
|
// through the now-guarded commands, proving the guard narrows but does not block.
|
|
test('#2104 — init execute-phase resolves a real foreign-prefixed phase dir', () => {
|
|
seedPhase(tmpDir, 'MEM-01-integration', {
|
|
'MEM-01-CONTEXT.md': '# Phase Context',
|
|
});
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({ project_code: 'LKML' }, null, 2),
|
|
);
|
|
|
|
const result = runGsdTools('init execute-phase MEM-01', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_found, true, 'a real MEM-01-* dir must resolve under its own prefix');
|
|
assert.strictEqual(output.phase_dir, absPlanningPath(tmpDir, 'phases', 'MEM-01-integration'));
|
|
});
|
|
|
|
test('#2104 — init verify-work resolves a real foreign-prefixed phase dir', () => {
|
|
seedPhase(tmpDir, 'MEM-01-integration', {
|
|
'MEM-01-CONTEXT.md': '# Phase Context',
|
|
});
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({ project_code: 'LKML' }, null, 2),
|
|
);
|
|
|
|
const result = runGsdTools('init verify-work MEM-01', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_found, true, 'a real MEM-01-* dir must resolve under its own prefix');
|
|
});
|
|
|
|
test('#2104 — init phase-op does not collapse foreign-prefixed task IDs', () => {
|
|
seedPhase(tmpDir, '01-stable-baseline-on-main', {
|
|
'01-CONTEXT.md': '# Phase Context',
|
|
});
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
'# Roadmap\n\n### Phase 1: Stable Baseline On Main\n**Goal:** Establish baseline\n**Plans:** 1 plan\n',
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({ project_code: 'LKML' }, null, 2),
|
|
);
|
|
|
|
const result = runGsdTools('init phase-op MEM-01', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_found, false, 'MEM-01 must NOT resolve to numeric Phase 01');
|
|
assert.strictEqual(output.phase_number, null);
|
|
});
|
|
|
|
test('init plan-phase exposes text_mode from config (defaults false)', () => {
|
|
const result = runGsdTools('init plan-phase 03', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.text_mode, false, 'text_mode should default to false');
|
|
});
|
|
|
|
test('init plan-phase exposes text_mode true when set in config', () => {
|
|
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
|
const existing = fs.existsSync(configPath)
|
|
? JSON.parse(fs.readFileSync(configPath, 'utf8'))
|
|
: {};
|
|
const config = { ...existing, workflow: { ...(existing.workflow || {}), text_mode: true } };
|
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
|
|
const result = runGsdTools('init plan-phase 03', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.text_mode, true, 'text_mode should reflect config value');
|
|
});
|
|
|
|
test('init progress returns file paths', () => {
|
|
const result = runGsdTools('init progress', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.state_path, absPlanningPath(tmpDir, 'STATE.md'));
|
|
assert.strictEqual(output.roadmap_path, absPlanningPath(tmpDir, 'ROADMAP.md'));
|
|
assert.strictEqual(output.project_path, absPlanningPath(tmpDir, 'PROJECT.md'));
|
|
assert.strictEqual(output.config_path, absPlanningPath(tmpDir, 'config.json'));
|
|
});
|
|
|
|
test('init phase-op returns core and optional phase file paths', () => {
|
|
seedPhase(tmpDir, '03-api', {
|
|
'03-CONTEXT.md': '# Phase Context',
|
|
'03-RESEARCH.md': '# Research',
|
|
'03-VERIFICATION.md': '# Verification',
|
|
'03-UAT.md': '# UAT',
|
|
});
|
|
// #3188: present-case assertions — create the planning docs the emitter keys on.
|
|
writePlanningDocs(tmpDir);
|
|
|
|
const result = runGsdTools('init phase-op 03', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.state_path, absPlanningPath(tmpDir, 'STATE.md'));
|
|
assert.strictEqual(output.roadmap_path, absPlanningPath(tmpDir, 'ROADMAP.md'));
|
|
assert.strictEqual(output.requirements_path, absPlanningPath(tmpDir, 'REQUIREMENTS.md'));
|
|
assert.strictEqual(output.context_path, absPlanningPath(tmpDir, 'phases', '03-api', '03-CONTEXT.md'));
|
|
assert.strictEqual(output.research_path, absPlanningPath(tmpDir, 'phases', '03-api', '03-RESEARCH.md'));
|
|
assert.strictEqual(output.verification_path, absPlanningPath(tmpDir, 'phases', '03-api', '03-VERIFICATION.md'));
|
|
assert.strictEqual(output.uat_path, absPlanningPath(tmpDir, 'phases', '03-api', '03-UAT.md'));
|
|
});
|
|
|
|
// #3473 F2 (companion to #3357): init phase-op's verification_path projector
|
|
// — the second of the two now-fixed init.cts sites — same regression as
|
|
// the plan-phase test above.
|
|
test('#3473 F2: init phase-op resolves the canonical report over a -CORRECTION- worksheet', () => {
|
|
seedPhase(tmpDir, '03-api', {
|
|
'03-CORRECTION-VERIFICATION.md': '# Ad-hoc correction worksheet',
|
|
'03-VERIFICATION.md': '# Verification',
|
|
});
|
|
writePlanningDocs(tmpDir);
|
|
|
|
const result = runGsdTools('init phase-op 03', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(
|
|
output.verification_path,
|
|
absPlanningPath(tmpDir, 'phases', '03-api', '03-VERIFICATION.md'),
|
|
'the canonical 03-VERIFICATION.md must win over the CORRECTION worksheet',
|
|
);
|
|
});
|
|
|
|
// #3518: init phase-op's uat_path projector — the second of the two
|
|
// single-pick UAT sites in src/init.cts — same phase-pinned contract as
|
|
// the plan-phase test above. Stray created first AND sorting first, so the
|
|
// pre-fix readdir-order pick deterministically chose it on both ordering
|
|
// families.
|
|
test('#3518: init phase-op uat_path is phase-pinned — a stray cross-phase -UAT.md must not win', () => {
|
|
seedPhase(tmpDir, '03-api', {
|
|
'02-UAT.md': '# Stray cross-phase UAT artifact (belongs to phase 02)',
|
|
'03-UAT.md': '# UAT',
|
|
});
|
|
writePlanningDocs(tmpDir);
|
|
|
|
const result = runGsdTools('init phase-op 03', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(
|
|
output.uat_path,
|
|
absPlanningPath(tmpDir, 'phases', '03-api', '03-UAT.md'),
|
|
'the phase\'s own 03-UAT.md must win over the stray cross-phase 02-UAT.md',
|
|
);
|
|
});
|
|
|
|
test('init plan-phase detects has_reviews and reviews_path when REVIEWS.md exists', () => {
|
|
seedPhase(tmpDir, '03-api', {
|
|
'03-REVIEWS.md': '# Cross-AI Reviews',
|
|
});
|
|
|
|
const result = runGsdTools('init plan-phase 03', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.has_reviews, true);
|
|
assert.strictEqual(output.reviews_path, absPlanningPath(tmpDir, 'phases', '03-api', '03-REVIEWS.md'));
|
|
});
|
|
|
|
test('init plan-phase omits optional paths if files missing', () => {
|
|
seedPhase(tmpDir, '03-api');
|
|
|
|
const result = runGsdTools('init plan-phase 03', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.context_path, undefined);
|
|
assert.strictEqual(output.research_path, undefined);
|
|
assert.strictEqual(output.reviews_path, undefined);
|
|
assert.strictEqual(output.has_reviews, false);
|
|
});
|
|
|
|
// ── phase_req_ids extraction (fix for #684) ──────────────────────────────
|
|
|
|
test('init plan-phase extracts phase_req_ids from ROADMAP', () => {
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-api'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap\n\n### Phase 3: API\n**Goal:** Build API\n**Requirements**: CP-01, CP-02, CP-03\n**Plans:** 0 plans\n`
|
|
);
|
|
|
|
const result = runGsdTools('init plan-phase 3', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_req_ids, 'CP-01, CP-02, CP-03');
|
|
});
|
|
|
|
test('init plan-phase strips brackets from phase_req_ids', () => {
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-api'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap\n\n### Phase 3: API\n**Goal:** Build API\n**Requirements**: [CP-01, CP-02]\n**Plans:** 0 plans\n`
|
|
);
|
|
|
|
const result = runGsdTools('init plan-phase 3', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_req_ids, 'CP-01, CP-02');
|
|
});
|
|
|
|
test('init plan-phase returns null phase_req_ids when Requirements line is absent', () => {
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-api'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap\n\n### Phase 3: API\n**Goal:** Build API\n**Plans:** 0 plans\n`
|
|
);
|
|
|
|
const result = runGsdTools('init plan-phase 3', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_req_ids, null);
|
|
});
|
|
|
|
test('init plan-phase returns null phase_req_ids when ROADMAP is absent', () => {
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-api'), { recursive: true });
|
|
|
|
const result = runGsdTools('init plan-phase 3', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_req_ids, null);
|
|
});
|
|
|
|
test('init execute-phase extracts phase_req_ids from ROADMAP', () => {
|
|
seedPhase(tmpDir, '03-api', {
|
|
'03-01-PLAN.md': '# Plan',
|
|
});
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap\n\n### Phase 3: API\n**Goal:** Build API\n**Requirements**: EX-01, EX-02\n**Plans:** 1 plans\n`
|
|
);
|
|
|
|
const result = runGsdTools('init execute-phase 3', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_req_ids, 'EX-01, EX-02');
|
|
});
|
|
|
|
test('init plan-phase returns null phase_req_ids when value is TBD', () => {
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-api'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap\n\n### Phase 3: API\n**Goal:** Build API\n**Requirements**: TBD\n**Plans:** 0 plans\n`
|
|
);
|
|
|
|
const result = runGsdTools('init plan-phase 3', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_req_ids, null, 'TBD placeholder should return null');
|
|
});
|
|
|
|
// ── #2769: Requirements header bold/colon variants ───────────────────────
|
|
// The visible label "**Requirements:**" (colon INSIDE bold) and
|
|
// "**Requirements**:" (colon OUTSIDE bold) render identically. The parser
|
|
// must accept both, plus the spaced "**Requirements** :" variant and the
|
|
// plain "## Requirements" header form (used in REQUIREMENTS.md), so phase
|
|
// metadata is robust to authoring style.
|
|
const headerVariants = [
|
|
{ name: 'colon inside bold (**Requirements:**)', header: '**Requirements:** RV-01, RV-02' },
|
|
{ name: 'colon outside bold (**Requirements**:)', header: '**Requirements**: RV-01, RV-02' },
|
|
{ name: 'space before colon (**Requirements** :)', header: '**Requirements** : RV-01, RV-02' },
|
|
];
|
|
|
|
for (const variant of headerVariants) {
|
|
test(`init plan-phase parses Requirements with ${variant.name}`, () => {
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-api'), { recursive: true });
|
|
const roadmap = [
|
|
'# Roadmap',
|
|
'',
|
|
'### Phase 3: API',
|
|
'**Goal:** Build API',
|
|
variant.header,
|
|
'**Plans:** 0 plans',
|
|
'',
|
|
].join('\n');
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
|
|
|
|
const result = runGsdTools('init plan-phase 3', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_req_ids, 'RV-01, RV-02',
|
|
`phase_req_ids must be parsed when header uses "${variant.header}"`);
|
|
});
|
|
|
|
test(`init execute-phase parses Requirements with ${variant.name}`, () => {
|
|
seedPhase(tmpDir, '03-api', {
|
|
'03-01-PLAN.md': '# Plan',
|
|
});
|
|
const roadmap = [
|
|
'# Roadmap',
|
|
'',
|
|
'### Phase 3: API',
|
|
'**Goal:** Build API',
|
|
variant.header,
|
|
'**Plans:** 1 plans',
|
|
'',
|
|
].join('\n');
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
|
|
|
|
const result = runGsdTools('init execute-phase 3', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_req_ids, 'RV-01, RV-02',
|
|
`phase_req_ids must be parsed when header uses "${variant.header}"`);
|
|
});
|
|
}
|
|
|
|
test('init execute-phase returns null phase_req_ids when Requirements line is absent', () => {
|
|
seedPhase(tmpDir, '03-api', {
|
|
'03-01-PLAN.md': '# Plan',
|
|
});
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap\n\n### Phase 3: API\n**Goal:** Build API\n**Plans:** 1 plans\n`
|
|
);
|
|
|
|
const result = runGsdTools('init execute-phase 3', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_req_ids, null);
|
|
});
|
|
|
|
test('init plan-phase resolves phase_req_ids from flat Phase Details after active milestone heading', () => {
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '11-second-active-phase'), { recursive: true });
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), [
|
|
'---',
|
|
'milestone: v0.4.0',
|
|
'current_phase: 11',
|
|
'---',
|
|
'',
|
|
].join('\n'));
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), [
|
|
'# Roadmap: Example',
|
|
'',
|
|
'## Milestones',
|
|
'',
|
|
'- ✅ **v0.3.0 Foundations** - Phases 1-9 (shipped 2026-01-01)',
|
|
'- 🚧 **v0.4.0 Feature Work** - Phases 10-11 (in progress)',
|
|
'',
|
|
'## Phases',
|
|
'',
|
|
'<details>',
|
|
'<summary>✅ v0.3.0 Foundations (Phases 1-9) - SHIPPED 2026-01-01</summary>',
|
|
'',
|
|
'- [x] **Phase 1: Bootstrap**',
|
|
'',
|
|
'</details>',
|
|
'',
|
|
'### 🚧 v0.4.0 Feature Work (Active)',
|
|
'',
|
|
'**Milestone Goal:** Deliver the feature set.',
|
|
'',
|
|
'- [ ] **Phase 10: First Active Phase**',
|
|
'- [ ] **Phase 11: Second Active Phase**',
|
|
'',
|
|
'### 📋 v0.5+ (Planned)',
|
|
'',
|
|
'## Phase Details',
|
|
'',
|
|
'### Phase 10: First Active Phase',
|
|
'**Goal**: Build the first piece.',
|
|
'**Requirements**: REQ-01',
|
|
'',
|
|
'### Phase 11: Second Active Phase',
|
|
'**Goal**: Build the second piece.',
|
|
'**Requirements**: REQ-02, REQ-03',
|
|
'',
|
|
'## Progress',
|
|
'',
|
|
].join('\n'));
|
|
|
|
const result = runGsdTools('init plan-phase 11', tmpDir);
|
|
assert.ok(result.success, `init plan-phase failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_found, true);
|
|
assert.strictEqual(output.phase_req_ids, 'REQ-02, REQ-03');
|
|
});
|
|
|
|
test('init execute-phase resolves phase_req_ids from flat Phase Details after active milestone heading', () => {
|
|
seedPhase(tmpDir, '11-second-active-phase', {
|
|
'11-01-PLAN.md': '# Plan',
|
|
});
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), [
|
|
'---',
|
|
'milestone: v0.4.0',
|
|
'current_phase: 11',
|
|
'---',
|
|
'',
|
|
].join('\n'));
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), [
|
|
'# Roadmap: Example',
|
|
'',
|
|
'## Phases',
|
|
'',
|
|
'### 🚧 v0.4.0 Feature Work (Active)',
|
|
'',
|
|
'- [ ] **Phase 10: First Active Phase**',
|
|
'- [ ] **Phase 11: Second Active Phase**',
|
|
'',
|
|
'### 📋 v0.5+ (Planned)',
|
|
'',
|
|
'## Phase Details',
|
|
'',
|
|
'### Phase 10: First Active Phase',
|
|
'**Goal**: Build the first piece.',
|
|
'**Requirements**: REQ-01',
|
|
'',
|
|
'### Phase 11: Second Active Phase',
|
|
'**Goal**: Build the second piece.',
|
|
'**Requirements**: REQ-02, REQ-03',
|
|
'',
|
|
].join('\n'));
|
|
|
|
const result = runGsdTools('init execute-phase 11', tmpDir);
|
|
assert.ok(result.success, `init execute-phase failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_found, true);
|
|
assert.strictEqual(output.phase_req_ids, 'REQ-02, REQ-03');
|
|
});
|
|
|
|
test('init plan-phase prefers real phase details outside fenced examples and ignores backlog sentinels (#1588)', () => {
|
|
const projectDir = createTempProject('init-1588-');
|
|
try {
|
|
fs.writeFileSync(
|
|
path.join(projectDir, '.planning', 'STATE.md'),
|
|
[
|
|
'---',
|
|
'gsd_state_version: 1.0',
|
|
'milestone: v1.1',
|
|
'status: planning',
|
|
'---',
|
|
'',
|
|
].join('\n')
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(projectDir, '.planning', 'ROADMAP.md'),
|
|
[
|
|
'# Roadmap',
|
|
'',
|
|
'<details open>',
|
|
'<summary>v1.1 Current (Phases 8-9) - PLANNED</summary>',
|
|
'',
|
|
'- [ ] **Phase 9: Real Phase**',
|
|
'',
|
|
'</details>',
|
|
'',
|
|
'## Phase Details',
|
|
'',
|
|
'```markdown',
|
|
'### Phase 9: Fenced Example Phase',
|
|
'**Goal:** This example must not be treated as roadmap structure.',
|
|
'```',
|
|
'',
|
|
'### Phase 9: Real Phase',
|
|
'**Goal:** Use the real phase details outside the fenced block.',
|
|
'**Requirements:** REAL-01',
|
|
'',
|
|
'## Backlog',
|
|
'',
|
|
'### Phase 999.1: Backlog Thing',
|
|
'**Goal:** Future backlog item.',
|
|
'',
|
|
].join('\n')
|
|
);
|
|
|
|
const phase9 = runGsdTools('init plan-phase 9', projectDir);
|
|
assert.ok(phase9.success, `init plan-phase 9 failed: ${phase9.error}`);
|
|
const phase9Output = JSON.parse(phase9.output);
|
|
assert.equal(phase9Output.phase_name, 'Real Phase');
|
|
assert.equal(phase9Output.phase_req_ids, 'REAL-01');
|
|
|
|
const backlog = runGsdTools('init plan-phase 999.1', projectDir);
|
|
assert.ok(backlog.success, `init plan-phase 999.1 failed: ${backlog.error}`);
|
|
const backlogOutput = JSON.parse(backlog.output);
|
|
assert.equal(backlogOutput.phase_found, false);
|
|
} finally {
|
|
cleanup(projectDir);
|
|
}
|
|
});
|
|
|
|
test('init phase-op resolves a details-summary milestone phase from later flat Phase Details', () => {
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), [
|
|
'milestone: v1.11',
|
|
'current_phase: 86',
|
|
'',
|
|
].join('\n'));
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), [
|
|
'# Roadmap',
|
|
'',
|
|
'## Phases',
|
|
'<details open>',
|
|
'<summary>🔄 v1.11 A06 (Phases 86-91) — IN PROGRESS</summary>',
|
|
'',
|
|
'- [ ] **Phase 86: Details Block Regression** — Parser should resolve this (DATA-01)',
|
|
'- [ ] **Phase 87: Other Work** — Later phase',
|
|
'</details>',
|
|
'',
|
|
'## Phase Details',
|
|
'',
|
|
'### Phase 86: Details Block Regression',
|
|
'**Goal**: Resolve phase details after collapsed milestone block',
|
|
'**Requirements**: DATA-01',
|
|
'',
|
|
'### Phase 87: Other Work',
|
|
'**Goal**: Not relevant',
|
|
'',
|
|
].join('\n'));
|
|
|
|
const result = runGsdTools('init phase-op 86', tmpDir);
|
|
assert.ok(result.success, `init phase-op failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_found, true);
|
|
assert.strictEqual(output.phase_name, 'Details Block Regression');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// #3188: init execute-phase / plan-phase / phase-op must return null for
|
|
// state_path / roadmap_path / requirements_path when the planning doc is
|
|
// absent — matching the contract the conditional sibling fields (patterns_path,
|
|
// context_path, …) already honour, and that ultraplan-phase.md:104 gates on.
|
|
// The WRITING emitters (new-project / new-milestone / ingest-docs) are
|
|
// intentionally NOT changed and keep returning a non-null write-target path.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('#3188 — init query path fields are null when the planning file is absent', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
// #2376 macOS fix: realpath so absPlanningPath matches process.cwd()-anchored output.
|
|
tmpDir = fs.realpathSync(createFixture());
|
|
seedPhase(tmpDir, '03-api', { '03-01-PLAN.md': '# Plan' });
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
// All three READING (projection) sites share the identical field group; the
|
|
// absent/present boundary must hold uniformly across them.
|
|
const COMMANDS = [
|
|
['init execute-phase', 'init execute-phase 03'],
|
|
['init plan-phase', 'init plan-phase 03'],
|
|
['init phase-op', 'init phase-op 03'],
|
|
];
|
|
|
|
for (const [label, argv] of COMMANDS) {
|
|
test(`${label}: state_path / roadmap_path / requirements_path are null when the docs are absent`, () => {
|
|
// No STATE.md / ROADMAP.md / REQUIREMENTS.md written.
|
|
const result = runGsdTools(argv, tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.state_path, null,
|
|
'state_path must be null when STATE.md is absent');
|
|
assert.strictEqual(output.roadmap_path, null,
|
|
'roadmap_path must be null when ROADMAP.md is absent');
|
|
assert.strictEqual(output.requirements_path, null,
|
|
'requirements_path must be null when REQUIREMENTS.md is absent');
|
|
});
|
|
|
|
test(`${label}: state_path / roadmap_path / requirements_path are absolute when the docs exist`, () => {
|
|
writePlanningDocs(tmpDir);
|
|
|
|
const result = runGsdTools(argv, tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.state_path, absPlanningPath(tmpDir, 'STATE.md'));
|
|
assert.strictEqual(output.roadmap_path, absPlanningPath(tmpDir, 'ROADMAP.md'));
|
|
assert.strictEqual(output.requirements_path, absPlanningPath(tmpDir, 'REQUIREMENTS.md'));
|
|
});
|
|
}
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// ROADMAP fallback for init plan-phase / execute-phase / verify-work (#1238)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('init commands ROADMAP fallback when phase directory does not exist (#1238)', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createFixture();
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
'# Roadmap\n\n### Phase 1: Foundation Setup\n**Goal:** Bootstrap project\n**Requirements**: R-01, R-02\n**Plans:** TBD\n'
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('init plan-phase falls back to ROADMAP when no phase directory exists', () => {
|
|
const result = runGsdTools('init plan-phase 1', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_found, true, 'phase_found should be true from ROADMAP fallback');
|
|
assert.strictEqual(output.phase_dir, null, 'phase_dir should be null (no directory yet)');
|
|
assert.strictEqual(output.phase_number, '1');
|
|
assert.strictEqual(output.phase_name, 'Foundation Setup');
|
|
assert.strictEqual(output.phase_slug, 'foundation-setup');
|
|
assert.strictEqual(output.padded_phase, '01');
|
|
});
|
|
|
|
test('init execute-phase falls back to ROADMAP when no phase directory exists', () => {
|
|
const result = runGsdTools('init execute-phase 1', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_found, true, 'phase_found should be true from ROADMAP fallback');
|
|
assert.strictEqual(output.phase_dir, null, 'phase_dir should be null (no directory yet)');
|
|
assert.strictEqual(output.phase_number, '1');
|
|
assert.strictEqual(output.phase_name, 'Foundation Setup');
|
|
assert.strictEqual(output.phase_slug, 'foundation-setup');
|
|
assert.strictEqual(output.phase_req_ids, 'R-01, R-02');
|
|
});
|
|
|
|
test('init verify-work falls back to ROADMAP when no phase directory exists', () => {
|
|
const result = runGsdTools('init verify-work 1', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_found, true, 'phase_found should be true from ROADMAP fallback');
|
|
assert.strictEqual(output.phase_dir, null, 'phase_dir should be null (no directory yet)');
|
|
assert.strictEqual(output.phase_number, '1');
|
|
assert.strictEqual(output.phase_name, 'Foundation Setup');
|
|
});
|
|
|
|
test('init plan-phase returns phase_found false when neither directory nor ROADMAP entry exists', () => {
|
|
const result = runGsdTools('init plan-phase 99', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_found, false);
|
|
assert.strictEqual(output.phase_dir, null);
|
|
assert.strictEqual(output.phase_number, null);
|
|
assert.strictEqual(output.phase_name, null);
|
|
});
|
|
|
|
test('init plan-phase prefers disk directory over ROADMAP fallback', () => {
|
|
seedPhase(tmpDir, '01-foundation-setup', {
|
|
'01-01-PLAN.md': '# Plan',
|
|
});
|
|
|
|
const result = runGsdTools('init plan-phase 1', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_found, true);
|
|
assert.ok(output.phase_dir !== null, 'phase_dir should point to disk directory');
|
|
assert.ok(output.phase_dir.includes('01-foundation-setup'));
|
|
assert.strictEqual(output.plan_count, 1);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// init ignores archived phases from prior milestones that share a phase number
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('init commands ignore archived phases from prior milestones sharing a number', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createFixture();
|
|
// Current milestone ROADMAP has Phase 2 but no disk directory yet
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
'# v2.0 Roadmap\n\n### Phase 2: New Feature\n**Goal:** New v2.0 feature\n**Requirements**: NEW-01, NEW-02\n**Plans:** TBD\n'
|
|
);
|
|
// Prior milestone archive has a shipped Phase 2 with different slug and artifacts
|
|
const archivedDir = path.join(tmpDir, '.planning', 'milestones', 'v1.0-phases', '02-old-feature');
|
|
fs.mkdirSync(archivedDir, { recursive: true });
|
|
fs.writeFileSync(path.join(archivedDir, '2-CONTEXT.md'), '# OLD v1.0 Phase 2 context');
|
|
fs.writeFileSync(path.join(archivedDir, '2-RESEARCH.md'), '# OLD v1.0 Phase 2 research');
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('init plan-phase prefers current ROADMAP entry over archived v1.0 phase of same number', () => {
|
|
const result = runGsdTools('init plan-phase 2', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_found, true);
|
|
assert.strictEqual(output.phase_name, 'New Feature',
|
|
'phase_name must come from current ROADMAP.md, not archived v1.0');
|
|
assert.strictEqual(output.phase_slug, 'new-feature');
|
|
assert.strictEqual(output.phase_dir, null,
|
|
'phase_dir must be null — current milestone has no directory yet');
|
|
assert.strictEqual(output.has_context, false,
|
|
'has_context must not inherit archived v1.0 artifacts');
|
|
assert.strictEqual(output.has_research, false,
|
|
'has_research must not inherit archived v1.0 artifacts');
|
|
assert.ok(!output.context_path,
|
|
'context_path must not point at archived v1.0 file');
|
|
assert.ok(!output.research_path,
|
|
'research_path must not point at archived v1.0 file');
|
|
assert.strictEqual(output.phase_req_ids, 'NEW-01, NEW-02');
|
|
});
|
|
|
|
test('init execute-phase prefers current ROADMAP entry over archived v1.0 phase of same number', () => {
|
|
const result = runGsdTools('init execute-phase 2', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_found, true);
|
|
assert.strictEqual(output.phase_name, 'New Feature');
|
|
assert.strictEqual(output.phase_slug, 'new-feature');
|
|
assert.strictEqual(output.phase_dir, null);
|
|
assert.strictEqual(output.phase_req_ids, 'NEW-01, NEW-02');
|
|
});
|
|
|
|
test('init verify-work prefers current ROADMAP entry over archived v1.0 phase of same number', () => {
|
|
const result = runGsdTools('init verify-work 2', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_found, true);
|
|
assert.strictEqual(output.phase_name, 'New Feature');
|
|
assert.strictEqual(output.phase_dir, null);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Bug #2391: zero-padded phase numbers must not bypass archived-phase guard
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('init plan-phase zero-padded phase number (bug #2391)', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createFixture();
|
|
// Current milestone ROADMAP has Phase 3 (unpadded heading)
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
'# v2.0 Roadmap\n\n### Phase 3: Rotation Engine + Availability\n**Goal**: Rotation\n**Requirements**: ROTA-01, ROTA-02\n**Plans:** TBD\n'
|
|
);
|
|
// Prior milestone archive has a shipped Phase 3 with different content
|
|
const archivedDir = path.join(tmpDir, '.planning', 'milestones', 'v1.0-phases', '03-plant-collection-and-rooms');
|
|
fs.mkdirSync(archivedDir, { recursive: true });
|
|
fs.writeFileSync(path.join(archivedDir, '03-CONTEXT.md'), '# OLD v1.0 Phase 3 context');
|
|
fs.writeFileSync(path.join(archivedDir, '03-RESEARCH.md'), '# OLD v1.0 Phase 3 research');
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('zero-padded "03" returns current ROADMAP phase, not archived v1.0 phase', () => {
|
|
const result = runGsdTools('init plan-phase 03', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_found, true);
|
|
assert.strictEqual(output.phase_name, 'Rotation Engine + Availability',
|
|
'phase_name must come from current ROADMAP.md, not the archived v1.0 phase');
|
|
assert.strictEqual(output.phase_dir, null,
|
|
'phase_dir must be null — current milestone has no directory yet');
|
|
assert.strictEqual(output.has_context, false,
|
|
'has_context must not inherit archived v1.0 artifacts');
|
|
assert.strictEqual(output.has_research, false,
|
|
'has_research must not inherit archived v1.0 artifacts');
|
|
assert.ok(!output.context_path || !output.context_path.includes('v1.0'),
|
|
'context_path must not point at archived v1.0 file');
|
|
assert.strictEqual(output.phase_req_ids, 'ROTA-01, ROTA-02');
|
|
});
|
|
|
|
test('unpadded "3" and zero-padded "03" return identical phase identity', () => {
|
|
const result3 = runGsdTools('init plan-phase 3', tmpDir);
|
|
const result03 = runGsdTools('init plan-phase 03', tmpDir);
|
|
assert.ok(result3.success && result03.success, 'both commands must succeed');
|
|
|
|
const out3 = JSON.parse(result3.output);
|
|
const out03 = JSON.parse(result03.output);
|
|
assert.strictEqual(out03.phase_name, out3.phase_name,
|
|
'phase_name must be identical regardless of padding');
|
|
assert.strictEqual(out03.phase_slug, out3.phase_slug,
|
|
'phase_slug must be identical regardless of padding');
|
|
assert.strictEqual(out03.phase_req_ids, out3.phase_req_ids,
|
|
'phase_req_ids must be identical regardless of padding');
|
|
});
|
|
|
|
// ── #904: branch_name must use normalized (stripped + zero-padded) phase number ──
|
|
// When project_code is set (e.g. "CK") the phase directory is prefixed:
|
|
// "CK-01-foundation". extractPhaseToken returns "CK-01" as phase_number.
|
|
// branch_name must call normalizePhaseName so it strips the prefix and zero-pads,
|
|
// producing "gsd/phase-01-foundation" rather than "gsd/phase-CK-01-foundation".
|
|
test('branch_name uses normalized phase number when project_code prefixes phase dir (#904)', () => {
|
|
seedPhase(tmpDir, 'CK-01-foundation', {
|
|
'CK-01-01-PLAN.md': '# Plan',
|
|
});
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({
|
|
project_code: 'CK',
|
|
git: {
|
|
branching_strategy: 'phase',
|
|
phase_branch_template: 'gsd/phase-{phase}-{slug}',
|
|
},
|
|
}, null, 2)
|
|
);
|
|
|
|
const result = runGsdTools('init execute-phase 1', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
// branch_name must use the normalized phase number, not the raw "CK-01" token
|
|
assert.strictEqual(output.branch_name, 'gsd/phase-01-foundation',
|
|
'branch_name must use normalized phase number (strip project_code prefix, zero-pad), not raw phase_number');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// cmdInitTodos (INIT-01)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('cmdInitTodos', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
// #2376 macOS fix: see 'init commands' beforeEach above.
|
|
tmpDir = fs.realpathSync(createFixture());
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('empty pending dir returns zero count', () => {
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'todos', 'pending'), { recursive: true });
|
|
|
|
const result = runGsdTools('init todos', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.todo_count, 0);
|
|
assert.deepStrictEqual(output.todos, []);
|
|
assert.strictEqual(output.pending_dir_exists, true);
|
|
});
|
|
|
|
test('missing pending dir returns zero count', () => {
|
|
const result = runGsdTools('init todos', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.todo_count, 0);
|
|
assert.deepStrictEqual(output.todos, []);
|
|
assert.strictEqual(output.pending_dir_exists, false);
|
|
});
|
|
|
|
test('multiple todos with fields are read correctly', () => {
|
|
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
|
|
fs.mkdirSync(pendingDir, { recursive: true });
|
|
|
|
fs.writeFileSync(path.join(pendingDir, 'task-1.md'), 'title: Fix bug\narea: backend\ncreated: 2026-02-25');
|
|
fs.writeFileSync(path.join(pendingDir, 'task-2.md'), 'title: Add feature\narea: frontend\ncreated: 2026-02-24');
|
|
fs.writeFileSync(path.join(pendingDir, 'task-3.md'), 'title: Write docs\narea: backend\ncreated: 2026-02-23');
|
|
|
|
const result = runGsdTools('init todos', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.todo_count, 3);
|
|
assert.strictEqual(output.todos.length, 3);
|
|
|
|
const task1 = output.todos.find(t => t.file === 'task-1.md');
|
|
assert.ok(task1, 'task-1.md should be in todos');
|
|
assert.strictEqual(task1.title, 'Fix bug');
|
|
assert.strictEqual(task1.area, 'backend');
|
|
assert.strictEqual(task1.created, '2026-02-25');
|
|
assert.strictEqual(task1.path, absPlanningPath(tmpDir, 'todos', 'pending', 'task-1.md'));
|
|
});
|
|
|
|
// ── #2337: init todos must surface severity too, in parity with list-todos ──
|
|
test('surfaces severity when present, omits the key when absent (#2337)', () => {
|
|
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
|
|
fs.mkdirSync(pendingDir, { recursive: true });
|
|
|
|
fs.writeFileSync(path.join(pendingDir, 'tagged.md'),
|
|
'title: Crash on save\narea: core\ncreated: 2026-02-25\nseverity: blocker');
|
|
fs.writeFileSync(path.join(pendingDir, 'untagged.md'),
|
|
'title: Old todo\narea: docs\ncreated: 2026-02-24');
|
|
|
|
const result = runGsdTools('init todos', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
const tagged = output.todos.find(t => t.file === 'tagged.md');
|
|
const untagged = output.todos.find(t => t.file === 'untagged.md');
|
|
assert.ok(tagged && untagged, 'both todos should be present');
|
|
assert.strictEqual(tagged.severity, 'blocker', 'severity surfaced verbatim when present');
|
|
assert.ok(!('severity' in untagged),
|
|
'severity key ABSENT (backward compatible) for a todo with no severity line');
|
|
});
|
|
|
|
test('area filter returns only matching todos', () => {
|
|
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
|
|
fs.mkdirSync(pendingDir, { recursive: true });
|
|
|
|
fs.writeFileSync(path.join(pendingDir, 'task-1.md'), 'title: Fix bug\narea: backend\ncreated: 2026-02-25');
|
|
fs.writeFileSync(path.join(pendingDir, 'task-2.md'), 'title: Add feature\narea: frontend\ncreated: 2026-02-24');
|
|
fs.writeFileSync(path.join(pendingDir, 'task-3.md'), 'title: Write docs\narea: backend\ncreated: 2026-02-23');
|
|
|
|
const result = runGsdTools('init todos backend', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.todo_count, 2);
|
|
assert.strictEqual(output.area_filter, 'backend');
|
|
for (const todo of output.todos) {
|
|
assert.strictEqual(todo.area, 'backend');
|
|
}
|
|
});
|
|
|
|
test('area filter miss returns zero count', () => {
|
|
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
|
|
fs.mkdirSync(pendingDir, { recursive: true });
|
|
|
|
fs.writeFileSync(path.join(pendingDir, 'task-1.md'), 'title: Fix bug\narea: backend\ncreated: 2026-02-25');
|
|
|
|
const result = runGsdTools('init todos nonexistent', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.todo_count, 0);
|
|
assert.strictEqual(output.area_filter, 'nonexistent');
|
|
});
|
|
|
|
test('malformed file uses defaults', () => {
|
|
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
|
|
fs.mkdirSync(pendingDir, { recursive: true });
|
|
|
|
fs.writeFileSync(path.join(pendingDir, 'broken.md'), 'some random content without fields');
|
|
|
|
const result = runGsdTools('init todos', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.todo_count, 1);
|
|
const todo = output.todos[0];
|
|
assert.strictEqual(todo.title, 'Untitled');
|
|
assert.strictEqual(todo.area, 'general');
|
|
assert.strictEqual(todo.created, 'unknown');
|
|
});
|
|
|
|
test('non-md files are ignored', () => {
|
|
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
|
|
fs.mkdirSync(pendingDir, { recursive: true });
|
|
|
|
fs.writeFileSync(path.join(pendingDir, 'task.md'), 'title: Real task\narea: dev\ncreated: 2026-01-01');
|
|
fs.writeFileSync(path.join(pendingDir, 'notes.txt'), 'title: Not a task\narea: dev\ncreated: 2026-01-01');
|
|
|
|
const result = runGsdTools('init todos', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.todo_count, 1);
|
|
assert.strictEqual(output.todos[0].file, 'task.md');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// cmdInitMilestoneOp (INIT-02)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('cmdInitMilestoneOp', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createFixture();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('no phase directories returns zero counts', () => {
|
|
const result = runGsdTools('init milestone-op', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_count, 0);
|
|
assert.strictEqual(output.completed_phases, 0);
|
|
assert.strictEqual(output.all_phases_complete, false);
|
|
});
|
|
|
|
test('multiple phases with no summaries', () => {
|
|
const phase1 = path.join(tmpDir, '.planning', 'phases', '01-setup');
|
|
const phase2 = path.join(tmpDir, '.planning', 'phases', '02-api');
|
|
fs.mkdirSync(phase1, { recursive: true });
|
|
fs.mkdirSync(phase2, { recursive: true });
|
|
fs.writeFileSync(path.join(phase1, '01-01-PLAN.md'), '# Plan');
|
|
fs.writeFileSync(path.join(phase2, '02-01-PLAN.md'), '# Plan');
|
|
|
|
const result = runGsdTools('init milestone-op', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_count, 2);
|
|
assert.strictEqual(output.completed_phases, 0);
|
|
assert.strictEqual(output.all_phases_complete, false);
|
|
});
|
|
|
|
test('mix of complete and incomplete phases', () => {
|
|
const phase1 = path.join(tmpDir, '.planning', 'phases', '01-setup');
|
|
const phase2 = path.join(tmpDir, '.planning', 'phases', '02-api');
|
|
fs.mkdirSync(phase1, { recursive: true });
|
|
fs.mkdirSync(phase2, { recursive: true });
|
|
fs.writeFileSync(path.join(phase1, '01-01-PLAN.md'), '# Plan');
|
|
fs.writeFileSync(path.join(phase1, '01-01-SUMMARY.md'), '# Summary');
|
|
fs.writeFileSync(path.join(phase2, '02-01-PLAN.md'), '# Plan');
|
|
|
|
const result = runGsdTools('init milestone-op', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_count, 2);
|
|
assert.strictEqual(output.completed_phases, 1);
|
|
assert.strictEqual(output.all_phases_complete, false);
|
|
});
|
|
|
|
test('all phases complete', () => {
|
|
const phase1 = path.join(tmpDir, '.planning', 'phases', '01-setup');
|
|
fs.mkdirSync(phase1, { recursive: true });
|
|
fs.writeFileSync(path.join(phase1, '01-01-PLAN.md'), '# Plan');
|
|
fs.writeFileSync(path.join(phase1, '01-01-SUMMARY.md'), '# Summary');
|
|
|
|
const result = runGsdTools('init milestone-op', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_count, 1);
|
|
assert.strictEqual(output.completed_phases, 1);
|
|
assert.strictEqual(output.all_phases_complete, true);
|
|
});
|
|
|
|
test('project_code-prefixed phase directories count as completed milestone phases (#1836)', () => {
|
|
seedPhase(tmpDir, 'PROJ-01-setup', {
|
|
'PROJ-01-01-PLAN.md': '# Plan',
|
|
'PROJ-01-01-SUMMARY.md': '# Summary',
|
|
});
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({ project_code: 'PROJ' }, null, 2)
|
|
);
|
|
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'STATE.md'),
|
|
[
|
|
'---',
|
|
'gsd_state_version: 1.0',
|
|
'milestone: v1.0.0',
|
|
'milestone_name: Test Milestone',
|
|
'status: executing',
|
|
'---',
|
|
'',
|
|
].join('\n')
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
[
|
|
'# Roadmap',
|
|
'',
|
|
'## 🚧 v1.0.0 Test Milestone',
|
|
'### Phase 1: Setup',
|
|
'',
|
|
].join('\n')
|
|
);
|
|
|
|
const result = runGsdTools('init milestone-op', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_count, 1);
|
|
assert.strictEqual(output.completed_phases, 1);
|
|
assert.strictEqual(output.all_phases_complete, true);
|
|
});
|
|
|
|
test('backlog 999.x headings do not inflate milestone phase counts (#1838)', () => {
|
|
const phase1 = path.join(tmpDir, '.planning', 'phases', '01-setup');
|
|
fs.mkdirSync(phase1, { recursive: true });
|
|
fs.writeFileSync(path.join(phase1, '01-01-PLAN.md'), '# Plan');
|
|
fs.writeFileSync(path.join(phase1, '01-01-SUMMARY.md'), '# Summary');
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'STATE.md'),
|
|
[
|
|
'---',
|
|
'gsd_state_version: 1.0',
|
|
'milestone: v1.0.0',
|
|
'milestone_name: Test Milestone',
|
|
'status: completed',
|
|
'---',
|
|
'',
|
|
].join('\n')
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
[
|
|
'# Roadmap',
|
|
'',
|
|
'## 🚧 v1.0.0 Test Milestone',
|
|
'### Phase 1: Setup',
|
|
'',
|
|
'## Backlog',
|
|
'### Phase 999.1: Deferred Idea',
|
|
'### Phase 999.2: Another Deferred Idea',
|
|
'',
|
|
].join('\n')
|
|
);
|
|
|
|
const result = runGsdTools('init milestone-op', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_count, 1);
|
|
assert.strictEqual(output.completed_phases, 1);
|
|
assert.strictEqual(output.all_phases_complete, true);
|
|
});
|
|
|
|
test('archive directory scanning', () => {
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'archive', 'v1.0'), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'archive', 'v0.9'), { recursive: true });
|
|
|
|
const result = runGsdTools('init milestone-op', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.archive_count, 2);
|
|
assert.strictEqual(output.archived_milestones.length, 2);
|
|
});
|
|
|
|
test('no archive directory returns empty', () => {
|
|
const result = runGsdTools('init milestone-op', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.archive_count, 0);
|
|
assert.deepStrictEqual(output.archived_milestones, []);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// cmdInitPhaseOp fallback (INIT-04)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('cmdInitPhaseOp fallback', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createFixture();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('normal path with existing directory', () => {
|
|
seedPhase(tmpDir, '03-api', {
|
|
'03-CONTEXT.md': '# Context',
|
|
'03-01-PLAN.md': '# Plan',
|
|
});
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
'# Roadmap\n\n### Phase 3: API\n**Goal:** Build API\n**Plans:** 1 plans\n'
|
|
);
|
|
|
|
const result = runGsdTools('init phase-op 3', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_found, true);
|
|
assert.ok(output.phase_dir.includes('03-api'), 'phase_dir should contain 03-api');
|
|
assert.strictEqual(output.has_context, true);
|
|
assert.strictEqual(output.has_plans, true);
|
|
});
|
|
|
|
test('fallback to ROADMAP when no directory exists', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
'# Roadmap\n\n### Phase 5: Widget Builder\n**Goal:** Build widgets\n**Plans:** TBD\n'
|
|
);
|
|
|
|
const result = runGsdTools('init phase-op 5', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_found, true);
|
|
assert.strictEqual(output.phase_dir, null);
|
|
assert.strictEqual(output.phase_slug, 'widget-builder');
|
|
assert.strictEqual(output.has_research, false);
|
|
assert.strictEqual(output.has_context, false);
|
|
assert.strictEqual(output.has_plans, false);
|
|
});
|
|
|
|
test('fallback resolves drifted project-code-prefixed roadmap heading by bare number (#1455)', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
'# Roadmap\n\n### Phase MANIFOLD-117: Prefixed Heading\n**Goal:** Build prefixed phase\n**Plans:** TBD\n'
|
|
);
|
|
|
|
const result = runGsdTools('init phase-op 117', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_found, true);
|
|
assert.strictEqual(output.phase_dir, null);
|
|
assert.strictEqual(output.phase_number, '117');
|
|
assert.strictEqual(output.phase_name, 'Prefixed Heading');
|
|
assert.strictEqual(output.phase_slug, 'prefixed-heading');
|
|
});
|
|
|
|
test('fallback resolves drifted project-code-prefixed roadmap heading by prefixed ID (#1455)', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
'# Roadmap\n\n### Phase MANIFOLD-117: Prefixed Heading\n**Goal:** Build prefixed phase\n**Plans:** TBD\n'
|
|
);
|
|
|
|
const result = runGsdTools('init phase-op MANIFOLD-117', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_found, true);
|
|
assert.strictEqual(output.phase_dir, null);
|
|
assert.strictEqual(output.phase_number, 'MANIFOLD-117');
|
|
assert.strictEqual(output.phase_name, 'Prefixed Heading');
|
|
assert.strictEqual(output.phase_slug, 'prefixed-heading');
|
|
});
|
|
|
|
test('prefers current milestone roadmap entry over archived phase with same number', () => {
|
|
const archiveDir = path.join(
|
|
tmpDir,
|
|
'.planning',
|
|
'milestones',
|
|
'v1.2-phases',
|
|
'02-event-parser-and-queue-schema'
|
|
);
|
|
fs.mkdirSync(archiveDir, { recursive: true });
|
|
fs.writeFileSync(path.join(archiveDir, '02-CONTEXT.md'), '# Archived context');
|
|
fs.writeFileSync(path.join(archiveDir, '02-01-PLAN.md'), '# Archived plan');
|
|
fs.writeFileSync(path.join(archiveDir, '02-VERIFICATION.md'), '# Archived verification');
|
|
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap
|
|
|
|
<details>
|
|
<summary>Shipped milestone v1.2</summary>
|
|
|
|
### Phase 2: Event Parser and Queue Schema
|
|
**Goal:** Archived milestone work
|
|
</details>
|
|
|
|
## Milestone v1.3 Current
|
|
|
|
### Phase 2: Retry Orchestration
|
|
**Goal:** Current milestone work
|
|
**Plans:** TBD
|
|
`
|
|
);
|
|
|
|
const result = runGsdTools('init phase-op 2', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_found, true);
|
|
assert.strictEqual(output.phase_dir, null);
|
|
assert.strictEqual(output.phase_name, 'Retry Orchestration');
|
|
assert.strictEqual(output.phase_slug, 'retry-orchestration');
|
|
assert.strictEqual(output.has_context, false);
|
|
assert.strictEqual(output.has_plans, false);
|
|
assert.strictEqual(output.has_verification, false);
|
|
});
|
|
|
|
test('neither directory nor roadmap entry returns not found', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
'# Roadmap\n\n### Phase 1: Setup\n**Goal:** Setup project\n**Plans:** TBD\n'
|
|
);
|
|
|
|
const result = runGsdTools('init phase-op 99', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_found, false);
|
|
assert.strictEqual(output.phase_dir, null);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// cmdInitProgress (INIT-03)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('cmdInitProgress', () => {
|
|
let tmpDir;
|
|
|
|
function writePassedVerification(phaseDir, phaseToken) {
|
|
fs.writeFileSync(
|
|
path.join(phaseDir, `${phaseToken}-VERIFICATION.md`),
|
|
['---', 'status: passed', '---', '', '# Verification', ''].join('\n')
|
|
);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createFixture();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('no phases returns empty state', () => {
|
|
const result = runGsdTools('init progress', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_count, 0);
|
|
assert.deepStrictEqual(output.phases, []);
|
|
assert.strictEqual(output.current_phase, null);
|
|
assert.strictEqual(output.next_phase, null);
|
|
assert.strictEqual(output.has_work_in_progress, false);
|
|
});
|
|
|
|
test('multiple phases with mixed statuses', () => {
|
|
// Phase 01: complete (has plan + summary)
|
|
const phase1 = path.join(tmpDir, '.planning', 'phases', '01-setup');
|
|
fs.mkdirSync(phase1, { recursive: true });
|
|
fs.writeFileSync(path.join(phase1, '01-01-PLAN.md'), '# Plan');
|
|
fs.writeFileSync(path.join(phase1, '01-01-SUMMARY.md'), '# Summary');
|
|
writePassedVerification(phase1, '01');
|
|
|
|
// Phase 02: in_progress (has plan, no summary)
|
|
const phase2 = path.join(tmpDir, '.planning', 'phases', '02-api');
|
|
fs.mkdirSync(phase2, { recursive: true });
|
|
fs.writeFileSync(path.join(phase2, '02-01-PLAN.md'), '# Plan');
|
|
|
|
// Phase 03: pending (no plan, no research)
|
|
const phase3 = path.join(tmpDir, '.planning', 'phases', '03-ui');
|
|
fs.mkdirSync(phase3, { recursive: true });
|
|
fs.writeFileSync(path.join(phase3, '03-CONTEXT.md'), '# Context');
|
|
|
|
const result = runGsdTools('init progress', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_count, 3);
|
|
assert.strictEqual(output.completed_count, 1);
|
|
assert.strictEqual(output.in_progress_count, 1);
|
|
assert.strictEqual(output.has_work_in_progress, true);
|
|
|
|
assert.strictEqual(output.current_phase.number, '02');
|
|
assert.strictEqual(output.current_phase.status, 'in_progress');
|
|
|
|
assert.strictEqual(output.next_phase.number, '03');
|
|
assert.strictEqual(output.next_phase.status, 'pending');
|
|
|
|
// Verify phase entries have expected structure
|
|
const p1 = output.phases.find(p => p.number === '01');
|
|
assert.strictEqual(p1.status, 'complete');
|
|
assert.strictEqual(p1.plan_count, 1);
|
|
assert.strictEqual(p1.summary_count, 1);
|
|
});
|
|
|
|
test('researched status detected correctly', () => {
|
|
const phase1 = path.join(tmpDir, '.planning', 'phases', '01-setup');
|
|
fs.mkdirSync(phase1, { recursive: true });
|
|
fs.writeFileSync(path.join(phase1, '01-RESEARCH.md'), '# Research');
|
|
|
|
const result = runGsdTools('init progress', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
const p1 = output.phases.find(p => p.number === '01');
|
|
assert.strictEqual(p1.status, 'researched');
|
|
assert.strictEqual(p1.has_research, true);
|
|
assert.strictEqual(output.current_phase.number, '01');
|
|
});
|
|
|
|
test('all phases complete returns no current or next', () => {
|
|
const phase1 = path.join(tmpDir, '.planning', 'phases', '01-setup');
|
|
fs.mkdirSync(phase1, { recursive: true });
|
|
fs.writeFileSync(path.join(phase1, '01-01-PLAN.md'), '# Plan');
|
|
fs.writeFileSync(path.join(phase1, '01-01-SUMMARY.md'), '# Summary');
|
|
writePassedVerification(phase1, '01');
|
|
|
|
const result = runGsdTools('init progress', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.completed_count, 1);
|
|
assert.strictEqual(output.current_phase, null);
|
|
assert.strictEqual(output.next_phase, null);
|
|
});
|
|
|
|
test('#3511-class: has_research ignores a misplaced RESEARCH.md that belongs to another phase', () => {
|
|
// Phase 01 has no artifacts of its own.
|
|
const phase1 = path.join(tmpDir, '.planning', 'phases', '01-setup');
|
|
fs.mkdirSync(phase1, { recursive: true });
|
|
|
|
// Phase 02's directory holds ONLY a stray artifact whose filename token
|
|
// ("01-") belongs to phase 01, not to this directory's own phase (02).
|
|
const phase2 = path.join(tmpDir, '.planning', 'phases', '02-api');
|
|
fs.mkdirSync(phase2, { recursive: true });
|
|
fs.writeFileSync(path.join(phase2, '01-RESEARCH.md'), '# Research for phase 01');
|
|
|
|
const result = runGsdTools('init progress', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
const p2 = output.phases.find(p => p.number === '02');
|
|
assert.strictEqual(p2.has_research, false,
|
|
'phase 02 must not report has_research from a file that belongs to phase 01');
|
|
assert.strictEqual(p2.status, 'pending');
|
|
});
|
|
|
|
test('implementation-complete phase without passed verification remains current work', () => {
|
|
const phase1 = path.join(tmpDir, '.planning', 'phases', '01-setup');
|
|
fs.mkdirSync(phase1, { recursive: true });
|
|
fs.writeFileSync(path.join(phase1, '01-01-PLAN.md'), '# Plan');
|
|
fs.writeFileSync(path.join(phase1, '01-01-SUMMARY.md'), '# Summary');
|
|
|
|
const result = runGsdTools('init progress', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.completed_count, 0);
|
|
assert.strictEqual(output.in_progress_count, 1);
|
|
assert.strictEqual(output.has_work_in_progress, true);
|
|
assert.strictEqual(output.current_phase.number, '01');
|
|
assert.strictEqual(output.current_phase.status, 'executed');
|
|
assert.strictEqual(output.current_phase.implementation_complete, true);
|
|
assert.strictEqual(output.current_phase.verification_status, 'missing');
|
|
assert.strictEqual(output.current_phase.verification_passed, false);
|
|
});
|
|
|
|
test('paused_at detected from STATE.md', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'STATE.md'),
|
|
'# Project State\n\n**Paused At:** Phase 2, Task 3 — implementing auth\n'
|
|
);
|
|
|
|
const result = runGsdTools('init progress', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.ok(output.paused_at, 'paused_at should be set');
|
|
assert.ok(output.paused_at.includes('Phase 2, Task 3'), 'paused_at should contain pause location');
|
|
});
|
|
|
|
test('no paused_at when STATE.md has no pause line', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'STATE.md'),
|
|
'# Project State\n\nSome content without pause.\n'
|
|
);
|
|
|
|
const result = runGsdTools('init progress', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.paused_at, null);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// cmdInitQuick (INIT-05)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('cmdInitQuick', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
// #2376 macOS fix: see 'init commands' beforeEach above.
|
|
tmpDir = fs.realpathSync(createFixture());
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('with description generates slug and task_dir with YYMMDD-xxx format', () => {
|
|
const result = runGsdTools('init quick "Fix login bug"', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.branch_name, null);
|
|
assert.strictEqual(output.slug, 'fix-login-bug');
|
|
assert.strictEqual(output.description, 'Fix login bug');
|
|
|
|
// quick_id must match YYMMDD-xxx (6 digits, dash, 3 base36 chars)
|
|
assert.ok(/^\d{6}-[0-9a-z]{3}$/.test(output.quick_id),
|
|
`quick_id should match YYMMDD-xxx, got: "${output.quick_id}"`);
|
|
|
|
// task_dir must use the new ID format, absolute (anchored on tmpDir) — #2376.
|
|
const quickDirAbs = absPlanningPath(tmpDir, 'quick');
|
|
assert.ok(output.task_dir.startsWith(`${quickDirAbs}/`),
|
|
`task_dir should start with ${quickDirAbs}/, got: "${output.task_dir}"`);
|
|
assert.ok(output.task_dir.endsWith('-fix-login-bug'),
|
|
`task_dir should end with -fix-login-bug, got: "${output.task_dir}"`);
|
|
const taskDirRel = output.task_dir.slice(quickDirAbs.length + 1);
|
|
assert.ok(/^\d{6}-[0-9a-z]{3}-fix-login-bug$/.test(taskDirRel),
|
|
`task_dir format wrong: "${output.task_dir}"`);
|
|
|
|
// next_num must NOT be present
|
|
assert.ok(!('next_num' in output), 'next_num should not be in output');
|
|
});
|
|
|
|
test('without description returns null slug and task_dir', () => {
|
|
const result = runGsdTools('init quick', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.slug, null);
|
|
assert.strictEqual(output.task_dir, null);
|
|
assert.strictEqual(output.description, null);
|
|
|
|
// quick_id is still generated even without description
|
|
assert.ok(/^\d{6}-[0-9a-z]{3}$/.test(output.quick_id),
|
|
`quick_id should match YYMMDD-xxx, got: "${output.quick_id}"`);
|
|
});
|
|
|
|
test('two rapid calls produce different quick_ids (no collision within 2s window)', () => {
|
|
// Both calls happen within the same test, which is sub-second.
|
|
// They may or may not land in the same 2-second block. We just verify format.
|
|
const r1 = runGsdTools('init quick "Task one"', tmpDir);
|
|
const r2 = runGsdTools('init quick "Task two"', tmpDir);
|
|
assert.ok(r1.success && r2.success);
|
|
|
|
const o1 = JSON.parse(r1.output);
|
|
const o2 = JSON.parse(r2.output);
|
|
|
|
assert.ok(/^\d{6}-[0-9a-z]{3}$/.test(o1.quick_id));
|
|
assert.ok(/^\d{6}-[0-9a-z]{3}$/.test(o2.quick_id));
|
|
|
|
// Directories are distinct because slugs differ
|
|
assert.notStrictEqual(o1.task_dir, o2.task_dir);
|
|
});
|
|
|
|
test('long description truncates slug to 40 chars', () => {
|
|
const result = runGsdTools('init quick "This is a very long description that should get truncated to forty characters maximum"', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.ok(output.slug.length <= 40, `Slug should be <= 40 chars, got ${output.slug.length}: "${output.slug}"`);
|
|
});
|
|
|
|
test('returns quick branch name when quick_branch_template is configured', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({
|
|
git: {
|
|
quick_branch_template: 'gsd/quick-{num}-{slug}',
|
|
},
|
|
}, null, 2)
|
|
);
|
|
|
|
const result = runGsdTools('init quick "Fix login bug"', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.ok(output.branch_name, 'branch_name should be set');
|
|
assert.ok(output.branch_name.startsWith('gsd/quick-'));
|
|
assert.ok(output.branch_name.endsWith('-fix-login-bug'));
|
|
assert.ok(output.branch_name.includes(output.quick_id), 'branch_name should include quick_id');
|
|
});
|
|
|
|
test('uses fallback slug in quick branch name when description is omitted', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({
|
|
git: {
|
|
quick_branch_template: 'gsd/quick-{quick}-{slug}',
|
|
},
|
|
}, null, 2)
|
|
);
|
|
|
|
const result = runGsdTools('init quick', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.ok(output.branch_name, 'branch_name should be set');
|
|
assert.ok(output.branch_name.endsWith('-quick'), `Expected fallback slug in branch name, got "${output.branch_name}"`);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// cmdInitQuick quick_id exact-value tests (#3314 — ADR-456 subprocess clock pin)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('cmdInitQuick quick_id — exact value under GSD_NOW_MS+TZ pin', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.realpathSync(createFixture());
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
// Computes the expected quick_id independently from the SAME algorithm
|
|
// documented in src/init.cts's cmdInitQuick — NOT copy-pasted from its
|
|
// runtime output — so this test can actually catch a broken implementation.
|
|
function expectedQuickId(ms) {
|
|
const d = new Date(ms);
|
|
const yy = String(d.getUTCFullYear()).slice(-2);
|
|
const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
|
|
const dd = String(d.getUTCDate()).padStart(2, '0');
|
|
const dateStr = yy + mm + dd;
|
|
const secondsSinceMidnight = d.getUTCHours() * 3600 + d.getUTCMinutes() * 60 + d.getUTCSeconds();
|
|
const timeBlocks = Math.floor(secondsSinceMidnight / 2);
|
|
const timeEncoded = timeBlocks.toString(36).padStart(3, '0');
|
|
return dateStr + '-' + timeEncoded;
|
|
}
|
|
|
|
test('quick_id: exact value for pinned instant', () => {
|
|
const PINNED_MS = 1_700_000_000_000; // 2023-11-14T22:13:20.000Z
|
|
const result = runGsdTools('init quick "pinned task"', tmpDir, {
|
|
GSD_TEST_MODE: '1', GSD_NOW_MS: String(PINNED_MS), TZ: 'UTC',
|
|
});
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.quick_id, expectedQuickId(PINNED_MS));
|
|
});
|
|
|
|
test('boundary: two calls in the same 2s block share quick_id (documented collision, not a bug)', () => {
|
|
const BLOCK_START_MS = 1_700_000_000_000; // aligned so +0 and +1000 fall in the same 2s block
|
|
const r1 = runGsdTools('init quick "task a"', tmpDir, {
|
|
GSD_TEST_MODE: '1', GSD_NOW_MS: String(BLOCK_START_MS), TZ: 'UTC',
|
|
});
|
|
const r2 = runGsdTools('init quick "task b"', tmpDir, {
|
|
GSD_TEST_MODE: '1', GSD_NOW_MS: String(BLOCK_START_MS + 1000), TZ: 'UTC',
|
|
});
|
|
assert.ok(r1.success && r2.success);
|
|
const o1 = JSON.parse(r1.output);
|
|
const o2 = JSON.parse(r2.output);
|
|
assert.strictEqual(o1.quick_id, expectedQuickId(BLOCK_START_MS));
|
|
assert.strictEqual(o2.quick_id, expectedQuickId(BLOCK_START_MS + 1000));
|
|
assert.strictEqual(o1.quick_id, o2.quick_id, 'both instants are in the same 2-second block and must share a quick_id');
|
|
});
|
|
|
|
test('boundary: two calls straddling a 2s block edge get different quick_id', () => {
|
|
const BEFORE_EDGE_MS = 1_700_000_000_000; // even second → block boundary at +2000ms
|
|
const AFTER_EDGE_MS = BEFORE_EDGE_MS + 2000;
|
|
const r1 = runGsdTools('init quick "task a"', tmpDir, {
|
|
GSD_TEST_MODE: '1', GSD_NOW_MS: String(BEFORE_EDGE_MS), TZ: 'UTC',
|
|
});
|
|
const r2 = runGsdTools('init quick "task b"', tmpDir, {
|
|
GSD_TEST_MODE: '1', GSD_NOW_MS: String(AFTER_EDGE_MS), TZ: 'UTC',
|
|
});
|
|
assert.ok(r1.success && r2.success);
|
|
const o1 = JSON.parse(r1.output);
|
|
const o2 = JSON.parse(r2.output);
|
|
assert.strictEqual(o1.quick_id, expectedQuickId(BEFORE_EDGE_MS));
|
|
assert.strictEqual(o2.quick_id, expectedQuickId(AFTER_EDGE_MS));
|
|
assert.notStrictEqual(o1.quick_id, o2.quick_id, 'instants 2000ms apart cross a 2-second block edge and must differ');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// cmdInitMapCodebase (INIT-05)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('cmdInitMapCodebase', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createFixture();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('no codebase dir returns empty', () => {
|
|
const result = runGsdTools('init map-codebase', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.has_maps, false);
|
|
assert.deepStrictEqual(output.existing_maps, []);
|
|
assert.strictEqual(output.codebase_dir_exists, false);
|
|
});
|
|
|
|
test('with existing maps lists md files only', () => {
|
|
const codebaseDir = path.join(tmpDir, '.planning', 'codebase');
|
|
fs.mkdirSync(codebaseDir, { recursive: true });
|
|
fs.writeFileSync(path.join(codebaseDir, 'STACK.md'), '# Stack');
|
|
fs.writeFileSync(path.join(codebaseDir, 'ARCHITECTURE.md'), '# Architecture');
|
|
fs.writeFileSync(path.join(codebaseDir, 'notes.txt'), 'not a markdown file');
|
|
|
|
const result = runGsdTools('init map-codebase', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.has_maps, true);
|
|
assert.strictEqual(output.existing_maps.length, 2);
|
|
assert.ok(output.existing_maps.includes('STACK.md'), 'Should include STACK.md');
|
|
assert.ok(output.existing_maps.includes('ARCHITECTURE.md'), 'Should include ARCHITECTURE.md');
|
|
});
|
|
|
|
test('empty codebase dir returns no maps', () => {
|
|
const codebaseDir = path.join(tmpDir, '.planning', 'codebase');
|
|
fs.mkdirSync(codebaseDir, { recursive: true });
|
|
|
|
const result = runGsdTools('init map-codebase', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.has_maps, false);
|
|
assert.deepStrictEqual(output.existing_maps, []);
|
|
assert.strictEqual(output.codebase_dir_exists, true);
|
|
});
|
|
|
|
test('map-codebase workflow does not list OpenCode under runtimes without Task tool (#1316)', () => {
|
|
const workflow = fs.readFileSync(
|
|
path.join(__dirname, '..', 'gsd-core', 'workflows', 'map-codebase.md'), 'utf8'
|
|
);
|
|
// OpenCode must NOT appear in the "WITHOUT Task tool" / "NOT available" condition
|
|
const withoutLine = workflow.split(/\r?\n/).find(l =>
|
|
l.includes('NOT available') || l.includes('WITHOUT Task tool')
|
|
);
|
|
assert.ok(withoutLine, 'workflow should have a line about Task tool NOT being available');
|
|
assert.ok(!withoutLine.includes('OpenCode'), 'OpenCode must NOT be listed under runtimes WITHOUT Task tool');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// cmdInitNewProject (INIT-06)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('cmdInitNewProject', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createFixture();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('greenfield project with no code', () => {
|
|
const result = runGsdTools('init new-project', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.has_existing_code, false);
|
|
assert.strictEqual(output.has_package_file, false);
|
|
assert.strictEqual(output.is_brownfield, false);
|
|
assert.strictEqual(output.needs_codebase_map, false);
|
|
});
|
|
|
|
test('brownfield with package.json detected', () => {
|
|
fs.writeFileSync(path.join(tmpDir, 'package.json'), '{"name":"test"}');
|
|
|
|
const result = runGsdTools('init new-project', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.has_package_file, true);
|
|
assert.strictEqual(output.is_brownfield, true);
|
|
assert.strictEqual(output.needs_codebase_map, true);
|
|
});
|
|
|
|
test('brownfield with codebase map does not need map', () => {
|
|
fs.writeFileSync(path.join(tmpDir, 'package.json'), '{"name":"test"}');
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'codebase'), { recursive: true });
|
|
for (const name of ['STACK', 'ARCHITECTURE', 'STRUCTURE', 'CONVENTIONS', 'TESTING', 'INTEGRATIONS', 'CONCERNS']) {
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'codebase', `${name}.md`), `# ${name}\n`);
|
|
}
|
|
|
|
const result = runGsdTools('init new-project', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.is_brownfield, true);
|
|
assert.strictEqual(output.needs_codebase_map, false);
|
|
});
|
|
|
|
test('planning_exists flag is correct', () => {
|
|
const result = runGsdTools('init new-project', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.planning_exists, true);
|
|
});
|
|
|
|
test('brownfield with Kotlin files detected (Android project)', () => {
|
|
const srcDir = path.join(tmpDir, 'app', 'src', 'main');
|
|
fs.mkdirSync(srcDir, { recursive: true });
|
|
fs.writeFileSync(path.join(srcDir, 'MainActivity.kt'), 'class MainActivity');
|
|
|
|
const result = runGsdTools('init new-project', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.has_existing_code, true);
|
|
assert.strictEqual(output.is_brownfield, true);
|
|
});
|
|
|
|
test('brownfield with build.gradle detected (Android/Gradle project)', () => {
|
|
fs.writeFileSync(path.join(tmpDir, 'build.gradle'), 'apply plugin: "com.android.application"');
|
|
|
|
const result = runGsdTools('init new-project', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.has_package_file, true);
|
|
assert.strictEqual(output.is_brownfield, true);
|
|
assert.strictEqual(output.needs_codebase_map, true);
|
|
});
|
|
|
|
test('brownfield with build.gradle.kts detected (Kotlin DSL)', () => {
|
|
fs.writeFileSync(path.join(tmpDir, 'build.gradle.kts'), 'plugins { id("com.android.application") }');
|
|
|
|
const result = runGsdTools('init new-project', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.has_package_file, true);
|
|
assert.strictEqual(output.is_brownfield, true);
|
|
});
|
|
|
|
test('brownfield with pom.xml detected (Maven project)', () => {
|
|
fs.writeFileSync(path.join(tmpDir, 'pom.xml'), '<project></project>');
|
|
|
|
const result = runGsdTools('init new-project', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.has_package_file, true);
|
|
assert.strictEqual(output.is_brownfield, true);
|
|
});
|
|
|
|
test('brownfield with pubspec.yaml detected (Flutter/Dart project)', () => {
|
|
fs.writeFileSync(path.join(tmpDir, 'pubspec.yaml'), 'name: my_app');
|
|
|
|
const result = runGsdTools('init new-project', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.has_package_file, true);
|
|
assert.strictEqual(output.is_brownfield, true);
|
|
});
|
|
|
|
test('brownfield with Dart files detected', () => {
|
|
const libDir = path.join(tmpDir, 'lib');
|
|
fs.mkdirSync(libDir, { recursive: true });
|
|
fs.writeFileSync(path.join(libDir, 'main.dart'), 'void main() {}');
|
|
|
|
const result = runGsdTools('init new-project', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.has_existing_code, true);
|
|
assert.strictEqual(output.is_brownfield, true);
|
|
});
|
|
|
|
test('brownfield with C++ files detected', () => {
|
|
fs.writeFileSync(path.join(tmpDir, 'main.cpp'), 'int main() { return 0; }');
|
|
|
|
const result = runGsdTools('init new-project', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.has_existing_code, true);
|
|
assert.strictEqual(output.is_brownfield, true);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// cmdInitNewMilestone (INIT-06)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('cmdInitNewMilestone', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
// #2376 macOS fix: see 'init commands' beforeEach above.
|
|
tmpDir = fs.realpathSync(createFixture());
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('returns expected fields', () => {
|
|
const result = runGsdTools('init new-milestone', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.ok('current_milestone' in output, 'Should have current_milestone');
|
|
assert.ok('current_milestone_name' in output, 'Should have current_milestone_name');
|
|
assert.ok('researcher_model' in output, 'Should have researcher_model');
|
|
assert.ok('synthesizer_model' in output, 'Should have synthesizer_model');
|
|
assert.ok('roadmapper_model' in output, 'Should have roadmapper_model');
|
|
assert.ok('commit_docs' in output, 'Should have commit_docs');
|
|
assert.strictEqual(output.project_path, absPlanningPath(tmpDir, 'PROJECT.md'));
|
|
assert.strictEqual(output.roadmap_path, absPlanningPath(tmpDir, 'ROADMAP.md'));
|
|
assert.strictEqual(output.state_path, absPlanningPath(tmpDir, 'STATE.md'));
|
|
});
|
|
|
|
test('file existence flags reflect actual state', () => {
|
|
// Default: no STATE.md, ROADMAP.md, or PROJECT.md
|
|
const result1 = runGsdTools('init new-milestone', tmpDir);
|
|
assert.ok(result1.success, `Command failed: ${result1.error}`);
|
|
|
|
const output1 = JSON.parse(result1.output);
|
|
assert.strictEqual(output1.state_exists, false);
|
|
assert.strictEqual(output1.roadmap_exists, false);
|
|
assert.strictEqual(output1.project_exists, false);
|
|
|
|
// Create files and verify flags change
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '# State');
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap');
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'PROJECT.md'), '# Project');
|
|
|
|
const result2 = runGsdTools('init new-milestone', tmpDir);
|
|
assert.ok(result2.success, `Command failed: ${result2.error}`);
|
|
|
|
const output2 = JSON.parse(result2.output);
|
|
assert.strictEqual(output2.state_exists, true);
|
|
assert.strictEqual(output2.roadmap_exists, true);
|
|
assert.strictEqual(output2.project_exists, true);
|
|
});
|
|
|
|
test('reports latest completed milestone and archive target for reset flow', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'MILESTONES.md'),
|
|
'# Milestones\n\n## v1.2 Search Refresh (Shipped: 2026-02-18)\n\n---\n'
|
|
);
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06-refine-search'), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '07-polish'), { recursive: true });
|
|
|
|
const result = runGsdTools('init new-milestone', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.latest_completed_milestone, 'v1.2');
|
|
assert.strictEqual(output.latest_completed_milestone_name, 'Search Refresh');
|
|
assert.strictEqual(output.phase_dir_count, 2);
|
|
assert.strictEqual(output.phase_archive_path, absPlanningPath(tmpDir, 'milestones', 'v1.2-phases'));
|
|
});
|
|
|
|
test('reset flow metadata is null-safe when no milestones file exists', () => {
|
|
const result = runGsdTools('init new-milestone', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.latest_completed_milestone, null);
|
|
assert.strictEqual(output.latest_completed_milestone_name, null);
|
|
assert.strictEqual(output.phase_dir_count, 0);
|
|
assert.strictEqual(output.phase_archive_path, null);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// findProjectRoot integration — gsd-tools resolves project root from sub-repo
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('findProjectRoot integration via --cwd', () => {
|
|
let projectRoot;
|
|
|
|
beforeEach(() => {
|
|
projectRoot = createFixture();
|
|
// Add ROADMAP.md so init quick doesn't error
|
|
fs.writeFileSync(
|
|
path.join(projectRoot, '.planning', 'ROADMAP.md'),
|
|
'# Roadmap\n\n## Phase 1: Foundation\n**Goal:** Setup\n'
|
|
);
|
|
// Write sub_repos config
|
|
fs.writeFileSync(
|
|
path.join(projectRoot, '.planning', 'config.json'),
|
|
JSON.stringify({ sub_repos: ['backend', 'frontend'] })
|
|
);
|
|
// Create sub-repo directory
|
|
fs.mkdirSync(path.join(projectRoot, 'backend'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(projectRoot);
|
|
});
|
|
|
|
test('init quick from sub-repo CWD returns project_root pointing to parent', () => {
|
|
const backendDir = path.join(projectRoot, 'backend');
|
|
const result = runGsdTools(['init', 'quick', 'test task', '--cwd', backendDir]);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.ok('project_root' in output, 'Should have project_root');
|
|
assert.strictEqual(output.project_root, projectRoot, 'project_root should be the parent, not the sub-repo');
|
|
assert.ok(output.roadmap_exists, 'Should find ROADMAP.md at project root');
|
|
});
|
|
|
|
test('init quick from project root returns project_root as-is', () => {
|
|
const result = runGsdTools(['init', 'quick', 'test task', '--cwd', projectRoot]);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.project_root, projectRoot);
|
|
});
|
|
|
|
test('state load from sub-repo CWD reads project root config', () => {
|
|
// Write STATE.md at project root
|
|
fs.writeFileSync(
|
|
path.join(projectRoot, '.planning', 'STATE.md'),
|
|
'---\ncurrent_phase: 1\nphase_name: Foundation\n---\n# State\n'
|
|
);
|
|
|
|
const backendDir = path.join(projectRoot, 'backend');
|
|
const result = runGsdTools(['state', '--cwd', backendDir]);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
// Should find config from project root, not from backend/
|
|
assert.deepStrictEqual(output.config.sub_repos, ['backend', 'frontend'],
|
|
'Should read sub_repos from project root config');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// #2192: init plan-phase must include auto_advance, auto_chain_active, and mode
|
|
// so workflows don't need separate config-get calls that loop on Kimi K2.5
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('#2192: init plan-phase includes auto-advance config to prevent separate config-get loops', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createFixture();
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-auth'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
['# Roadmap', '', '## Milestone v1', '', '### Phase 1: Auth', '**Goal:** Auth'].join('\n')
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('init plan-phase includes auto_advance field (defaults false)', () => {
|
|
const result = runGsdTools('init plan-phase 1', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
const output = JSON.parse(result.output);
|
|
assert.ok('auto_advance' in output, 'init plan-phase must include auto_advance field');
|
|
assert.strictEqual(output.auto_advance, false, 'auto_advance should default to false');
|
|
});
|
|
|
|
test('init plan-phase includes auto_chain_active field (defaults false)', () => {
|
|
const result = runGsdTools('init plan-phase 1', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
const output = JSON.parse(result.output);
|
|
assert.ok('auto_chain_active' in output, 'init plan-phase must include auto_chain_active field');
|
|
assert.strictEqual(output.auto_chain_active, false, 'auto_chain_active should default to false');
|
|
});
|
|
|
|
test('init plan-phase includes mode field (defaults to interactive)', () => {
|
|
const result = runGsdTools('init plan-phase 1', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
const output = JSON.parse(result.output);
|
|
assert.ok('mode' in output, 'init plan-phase must include mode field');
|
|
assert.strictEqual(output.mode, 'interactive', 'mode should default to interactive');
|
|
});
|
|
|
|
test('init plan-phase reflects auto_advance true when set in config', () => {
|
|
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
|
const cfg = { workflow: { auto_advance: true } };
|
|
fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2));
|
|
|
|
const result = runGsdTools('init plan-phase 1', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.auto_advance, true, 'auto_advance should reflect config value');
|
|
});
|
|
|
|
test('init plan-phase reflects auto_chain_active true when set in config', () => {
|
|
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
|
const cfg = { workflow: { _auto_chain_active: true } };
|
|
fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2));
|
|
|
|
const result = runGsdTools('init plan-phase 1', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.auto_chain_active, true, 'auto_chain_active should reflect config value');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// withProjectRoot: project identity injection (#1948)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('withProjectRoot project identity', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createFixture();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('injects project_code when config.project_code is set', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({ project_code: 'CK' })
|
|
);
|
|
|
|
const result = runGsdTools(['init', 'quick', 'test task'], tmpDir, { HOME: tmpDir });
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.project_code, 'CK');
|
|
});
|
|
|
|
test('injects project_title extracted from PROJECT.md H1', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({ project_code: 'CK' })
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'PROJECT.md'),
|
|
'# CareKit\n\nA care management platform.\n'
|
|
);
|
|
|
|
const result = runGsdTools(['init', 'quick', 'test task'], tmpDir, { HOME: tmpDir });
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.project_title, 'CareKit');
|
|
});
|
|
|
|
test('omits project_code and project_title when project_code is not set', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({})
|
|
);
|
|
|
|
const result = runGsdTools(['init', 'quick', 'test task'], tmpDir, { HOME: tmpDir });
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.project_code, undefined,
|
|
'project_code should not be present when not configured');
|
|
// project_title may or may not be present depending on PROJECT.md existence,
|
|
// but without project_code the workflow omits the identity suffix entirely
|
|
});
|
|
|
|
test('omits project_title when PROJECT.md does not exist', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({ project_code: 'CK' })
|
|
);
|
|
// Ensure no PROJECT.md exists (createFixture doesn't create one)
|
|
const projectMdPath = path.join(tmpDir, '.planning', 'PROJECT.md');
|
|
if (fs.existsSync(projectMdPath)) fs.unlinkSync(projectMdPath);
|
|
|
|
const result = runGsdTools(['init', 'quick', 'test task'], tmpDir, { HOME: tmpDir });
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.project_code, 'CK',
|
|
'project_code should still be present');
|
|
assert.strictEqual(output.project_title, undefined,
|
|
'project_title should not be present when PROJECT.md is missing');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// ADR-0006: init handlers honor GSD_WORKSTREAM (planningPaths/planningDir consumption)
|
|
// Issue #1189 — regression guard: workstream-scoped paths must be resolved
|
|
// through planningDir(cwd) which picks up GSD_WORKSTREAM from env.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('init handlers honor GSD_WORKSTREAM (ADR-0006 planningPaths consumption)', () => {
|
|
const { seedWorkstream } = require('./fixtures/index.cjs');
|
|
|
|
/**
|
|
* Build a workstream-scoped fixture under tmpDir.
|
|
* Only workstream-scoped files exist; flat .planning/ has only the phases dir.
|
|
*/
|
|
function buildWsFixture(tmpDir, ws = 'wsx') {
|
|
const wsDir = seedWorkstream(tmpDir, { name: ws });
|
|
// Write the planning files ONLY under the workstream path
|
|
fs.writeFileSync(
|
|
path.join(wsDir, 'STATE.md'),
|
|
'# State\n'
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(wsDir, 'ROADMAP.md'),
|
|
'# Roadmap\n\n### Phase 1: Setup\n**Goal:** Bootstrap\n**Requirements**: R-01\n**Plans:** 1 plans\n'
|
|
);
|
|
// #3188: REQUIREMENTS.md present so the workstream-scoped requirements_path
|
|
// present-case assertion holds (STATE/ROADMAP already written above).
|
|
fs.writeFileSync(path.join(wsDir, 'REQUIREMENTS.md'), '# Requirements\n');
|
|
fs.writeFileSync(
|
|
path.join(wsDir, 'config.json'),
|
|
JSON.stringify({})
|
|
);
|
|
// Create a phase plan under the workstream
|
|
fs.mkdirSync(path.join(wsDir, 'phases', '01-setup'), { recursive: true });
|
|
fs.writeFileSync(path.join(wsDir, 'phases', '01-setup', '01-01-PLAN.md'), '# Plan\n');
|
|
return wsDir;
|
|
}
|
|
|
|
// ── Test 1: execute-phase — workstream-scoped path fields (happy) ─────────
|
|
|
|
describe('execute-phase — workstream-scoped path fields', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
// #2376 macOS fix: see 'init commands' beforeEach above.
|
|
tmpDir = fs.realpathSync(createFixture());
|
|
buildWsFixture(tmpDir, 'wsx');
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('execute-phase emits workstream-scoped state/roadmap/config paths (ADR-0006)', () => {
|
|
const result = runGsdTools('init execute-phase 1', tmpDir, { GSD_WORKSTREAM: 'wsx', GSD_PROJECT: '' });
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
// Positive: paths must be workstream-scoped
|
|
assert.strictEqual(output.state_path, absPlanningPath(tmpDir, 'workstreams', 'wsx', 'STATE.md'));
|
|
assert.strictEqual(output.roadmap_path, absPlanningPath(tmpDir, 'workstreams', 'wsx', 'ROADMAP.md'));
|
|
assert.strictEqual(output.config_path, absPlanningPath(tmpDir, 'workstreams', 'wsx', 'config.json'));
|
|
// Goodhart both-directions: must NOT be the flat form
|
|
assert.notStrictEqual(output.state_path, absPlanningPath(tmpDir, 'STATE.md'));
|
|
assert.notStrictEqual(output.roadmap_path, absPlanningPath(tmpDir, 'ROADMAP.md'));
|
|
assert.notStrictEqual(output.config_path, absPlanningPath(tmpDir, 'config.json'));
|
|
// phase_dir is emitted and must be workstream-scoped
|
|
assert.ok(
|
|
output.phase_dir && output.phase_dir.includes('workstreams/wsx'),
|
|
`phase_dir should include workstreams/wsx, got: ${output.phase_dir}`
|
|
);
|
|
});
|
|
|
|
test('execute-phase WITHOUT GSD_WORKSTREAM resolves flat paths (boundary control)', () => {
|
|
// Flat fixture: the workstream fixture exists but we do NOT pass GSD_WORKSTREAM.
|
|
// Handler should resolve flat .planning/ → state/roadmap/config are flat,
|
|
// and the workstream phase is NOT found (flat phases/ is empty).
|
|
// #3188: create the flat docs so the flat *_path present-case assertions hold.
|
|
writePlanningDocs(tmpDir);
|
|
const result = runGsdTools('init execute-phase 1', tmpDir, { GSD_WORKSTREAM: '', GSD_PROJECT: '' });
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
// Flat paths must be returned when no workstream is active
|
|
assert.strictEqual(output.state_path, absPlanningPath(tmpDir, 'STATE.md'));
|
|
assert.strictEqual(output.roadmap_path, absPlanningPath(tmpDir, 'ROADMAP.md'));
|
|
assert.strictEqual(output.config_path, absPlanningPath(tmpDir, 'config.json'));
|
|
// Phase is NOT found in flat .planning/phases/ (only exists under workstream)
|
|
assert.strictEqual(output.phase_found, false,
|
|
'phase should not be found in flat path when only workstream fixture exists');
|
|
});
|
|
});
|
|
|
|
// ── Test 2: milestone-op — reads workstream-scoped roadmap/state/phases ───
|
|
|
|
describe('milestone-op — reads workstream-scoped planning files', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
// Do NOT call createFixture (which would add flat .planning/phases/).
|
|
// Create a bare temp dir so files only exist under the workstream path.
|
|
const os = require('os');
|
|
tmpDir = fs.mkdtempSync(require('path').join(os.tmpdir(), 'gsd-test-'));
|
|
buildWsFixture(tmpDir, 'wsx');
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('milestone-op with GSD_WORKSTREAM finds roadmap/state/phases in workstream scope (ADR-0006)', () => {
|
|
const result = runGsdTools('init milestone-op', tmpDir, { GSD_WORKSTREAM: 'wsx', GSD_PROJECT: '' });
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.roadmap_exists, true,
|
|
'roadmap_exists must be true: ROADMAP.md exists only under workstream path');
|
|
assert.strictEqual(output.state_exists, true,
|
|
'state_exists must be true: STATE.md exists only under workstream path');
|
|
assert.strictEqual(output.phases_dir_exists, true,
|
|
'phases_dir_exists must be true: phases/ exists under workstream path');
|
|
});
|
|
|
|
test('milestone-op WITHOUT GSD_WORKSTREAM misses workstream-only files (negative discrimination)', () => {
|
|
const result = runGsdTools('init milestone-op', tmpDir, { GSD_WORKSTREAM: '', GSD_PROJECT: '' });
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
// Handler looked at flat .planning/ — no files there → all false
|
|
assert.strictEqual(output.roadmap_exists, false,
|
|
'roadmap_exists must be false: ROADMAP.md is only under workstream, not flat .planning/');
|
|
assert.strictEqual(output.state_exists, false,
|
|
'state_exists must be false: STATE.md is only under workstream, not flat .planning/');
|
|
});
|
|
});
|
|
|
|
// ── Test 3: plan-phase — workstream-scoped resolution ────────────────────
|
|
|
|
describe('plan-phase — workstream-scoped path resolution', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
// #2376 macOS fix: see 'init commands' beforeEach above.
|
|
tmpDir = fs.realpathSync(createFixture());
|
|
buildWsFixture(tmpDir, 'wsx');
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('plan-phase emits workstream-scoped state/roadmap/requirements paths (ADR-0006)', () => {
|
|
const result = runGsdTools('init plan-phase 1', tmpDir, { GSD_WORKSTREAM: 'wsx', GSD_PROJECT: '' });
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
// Path fields must be scoped to the workstream
|
|
assert.strictEqual(output.state_path, absPlanningPath(tmpDir, 'workstreams', 'wsx', 'STATE.md'));
|
|
assert.strictEqual(output.roadmap_path, absPlanningPath(tmpDir, 'workstreams', 'wsx', 'ROADMAP.md'));
|
|
assert.strictEqual(output.requirements_path, absPlanningPath(tmpDir, 'workstreams', 'wsx', 'REQUIREMENTS.md'));
|
|
// Must NOT be flat
|
|
assert.notStrictEqual(output.state_path, absPlanningPath(tmpDir, 'STATE.md'));
|
|
assert.notStrictEqual(output.roadmap_path, absPlanningPath(tmpDir, 'ROADMAP.md'));
|
|
// phase_dir is workstream-scoped and phase is found
|
|
assert.strictEqual(output.phase_found, true);
|
|
assert.ok(
|
|
output.phase_dir && output.phase_dir.includes('workstreams/wsx'),
|
|
`phase_dir should include workstreams/wsx, got: ${output.phase_dir}`
|
|
);
|
|
});
|
|
|
|
test('plan-phase WITHOUT GSD_WORKSTREAM resolves flat paths (boundary control)', () => {
|
|
// #3188: create the flat docs so the flat *_path present-case assertions hold.
|
|
writePlanningDocs(tmpDir);
|
|
const result = runGsdTools('init plan-phase 1', tmpDir, { GSD_WORKSTREAM: '', GSD_PROJECT: '' });
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.state_path, absPlanningPath(tmpDir, 'STATE.md'));
|
|
assert.strictEqual(output.roadmap_path, absPlanningPath(tmpDir, 'ROADMAP.md'));
|
|
assert.strictEqual(output.requirements_path, absPlanningPath(tmpDir, 'REQUIREMENTS.md'));
|
|
// Phase only exists under workstream, so not found via flat path
|
|
assert.strictEqual(output.phase_found, false);
|
|
});
|
|
});
|
|
|
|
// ── Test 4: phase-op — workstream-scoped phase resolution ────────────────
|
|
|
|
describe('phase-op — workstream-scoped phase resolution', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
// #2376 macOS fix: see 'init commands' beforeEach above.
|
|
tmpDir = fs.realpathSync(createFixture());
|
|
buildWsFixture(tmpDir, 'wsx');
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('phase-op with GSD_WORKSTREAM finds phase in workstream scope and emits scoped paths (ADR-0006)', () => {
|
|
const result = runGsdTools('init phase-op 1', tmpDir, { GSD_WORKSTREAM: 'wsx', GSD_PROJECT: '' });
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
// Phase is found via workstream-scoped phases dir
|
|
assert.strictEqual(output.phase_found, true,
|
|
'phase_found must be true: phase exists under workstream path');
|
|
assert.ok(
|
|
output.phase_dir && output.phase_dir.includes('workstreams/wsx'),
|
|
`phase_dir should include workstreams/wsx, got: ${output.phase_dir}`
|
|
);
|
|
// Path fields are workstream-scoped
|
|
assert.strictEqual(output.state_path, absPlanningPath(tmpDir, 'workstreams', 'wsx', 'STATE.md'));
|
|
assert.strictEqual(output.roadmap_path, absPlanningPath(tmpDir, 'workstreams', 'wsx', 'ROADMAP.md'));
|
|
assert.notStrictEqual(output.state_path, absPlanningPath(tmpDir, 'STATE.md'));
|
|
});
|
|
|
|
test('phase-op WITHOUT GSD_WORKSTREAM does not find workstream-only phase (negative discrimination)', () => {
|
|
// #3188: create the flat docs so the flat *_path present-case assertions hold.
|
|
writePlanningDocs(tmpDir);
|
|
const result = runGsdTools('init phase-op 1', tmpDir, { GSD_WORKSTREAM: '', GSD_PROJECT: '' });
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
// Phase only exists under workstream path — flat path has no matching phase dir
|
|
assert.strictEqual(output.phase_found, false,
|
|
'phase_found must be false: phase only exists under workstream path');
|
|
// Flat paths are emitted
|
|
assert.strictEqual(output.state_path, absPlanningPath(tmpDir, 'STATE.md'));
|
|
assert.strictEqual(output.roadmap_path, absPlanningPath(tmpDir, 'ROADMAP.md'));
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// #1912: init.progress fails safe in workstream mode with no active workstream
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('#1912 — init.progress fails safe in workstream mode with no active workstream', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => { tmpDir = createTempProject(); });
|
|
afterEach(() => { cleanup(tmpDir); });
|
|
|
|
function seedWs(name, milestoneVersion) {
|
|
const wsDir = path.join(tmpDir, '.planning', 'workstreams', name);
|
|
fs.mkdirSync(path.join(wsDir, 'phases'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(wsDir, 'STATE.md'),
|
|
`# State\n\n**Status:** executing\n**Milestone:** ${milestoneVersion}\n`,
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(wsDir, 'ROADMAP.md'),
|
|
`# Roadmap\n\n## Milestones\n- ${milestoneVersion} Test (Phase 1)\n\n## Phases\n### Phase 1: X\n**Goal:** do x\n`,
|
|
);
|
|
return wsDir;
|
|
}
|
|
|
|
test('errors (does NOT report stale root) when workstreams exist but none active', () => {
|
|
seedWs('alpha', 'v9.0');
|
|
seedWs('beta', 'v9.0');
|
|
// Stale root STATE — the misleading value that must never be silently reported.
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'milestone: v7.1\nstatus: executing\n');
|
|
// No active-workstream pointer, no --ws.
|
|
const result = runGsdTools('init progress', tmpDir);
|
|
assert.equal(result.success, false, 'should fail safe rather than report the stale root milestone');
|
|
assert.match(result.error || '', /workstream|--ws/i, 'error should name the workstream requirement');
|
|
});
|
|
|
|
test('succeeds with --ws (reads the named workstream, not root)', () => {
|
|
seedWs('alpha', 'v9.0');
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'milestone: v7.1\nstatus: executing\n');
|
|
const result = runGsdTools('init progress --ws alpha', tmpDir);
|
|
assert.ok(result.success, `should succeed with --ws: ${result.error}`);
|
|
});
|
|
|
|
test('flat mode (no workstreams dir) is unchanged', () => {
|
|
const result = runGsdTools('init progress', tmpDir);
|
|
assert.ok(result.success, `flat mode should still work: ${result.error}`);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// #2376: init.* path-shaped output fields must resolve regardless of the
|
|
// calling process's own cwd — not just the orchestrator's cwd. A spawned
|
|
// subagent's actual filesystem cwd can legitimately differ from the
|
|
// orchestrator's (e.g. a worktree), and every `*_path`/`*_dir` field handed
|
|
// to it must still resolve to the real file. These tests spawn gsd-tools with
|
|
// its OS-level process cwd pointed at an unrelated decoy directory while
|
|
// passing the real project root via `--cwd` — reproducing exactly the
|
|
// cwd-mismatch the bug hides behind, without relying on incidental absence
|
|
// of `.planning/` at the test runner's own cwd.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('#2376 — init.* path fields resolve when process cwd differs from --cwd project root', () => {
|
|
let projectDir;
|
|
let decoyDir;
|
|
|
|
beforeEach(() => {
|
|
projectDir = createFixture();
|
|
decoyDir = createTempDir('gsd-2376-decoy-');
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(projectDir);
|
|
cleanup(decoyDir);
|
|
});
|
|
|
|
test('init plan-phase emits absolute path fields that resolve from a different process cwd', () => {
|
|
seedPhase(projectDir, '03-api', {
|
|
'03-CONTEXT.md': '# Phase Context',
|
|
});
|
|
fs.writeFileSync(path.join(projectDir, '.planning', 'STATE.md'), '# State\n');
|
|
fs.writeFileSync(path.join(projectDir, '.planning', 'ROADMAP.md'), '# Roadmap\n');
|
|
fs.writeFileSync(path.join(projectDir, '.planning', 'REQUIREMENTS.md'), '# Requirements\n');
|
|
|
|
const result = runGsdTools(['init', 'plan-phase', '03', '--cwd', projectDir], decoyDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
for (const field of ['state_path', 'roadmap_path', 'requirements_path', 'context_path', 'phase_dir']) {
|
|
const value = output[field];
|
|
assert.ok(value, `${field} should be present, got: ${JSON.stringify(value)}`);
|
|
assert.ok(path.isAbsolute(value), `${field} must be absolute, got: "${value}"`);
|
|
// Resolve AS-IS — no joining against decoyDir or process.cwd() — the
|
|
// string itself must locate the real, already-committed file/dir.
|
|
assert.ok(fs.existsSync(value), `${field} must resolve to the real file/dir: "${value}"`);
|
|
}
|
|
});
|
|
|
|
test('init verify-work now emits absolute state_path/roadmap_path (previously absent)', () => {
|
|
seedPhase(projectDir, '03-api', {
|
|
'03-01-PLAN.md': '# Plan',
|
|
'03-01-SUMMARY.md': '# Summary',
|
|
});
|
|
fs.writeFileSync(path.join(projectDir, '.planning', 'STATE.md'), '# State\n');
|
|
fs.writeFileSync(path.join(projectDir, '.planning', 'ROADMAP.md'), '# Roadmap\n');
|
|
|
|
const result = runGsdTools(['init', 'verify-work', '03', '--cwd', projectDir], decoyDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.ok('state_path' in output, 'cmdInitVerifyWork must now emit state_path (#2376)');
|
|
assert.ok('roadmap_path' in output, 'cmdInitVerifyWork must now emit roadmap_path (#2376)');
|
|
for (const field of ['state_path', 'roadmap_path']) {
|
|
assert.ok(path.isAbsolute(output[field]), `${field} must be absolute, got: "${output[field]}"`);
|
|
assert.ok(fs.existsSync(output[field]), `${field} must resolve to the real file: "${output[field]}"`);
|
|
}
|
|
});
|
|
|
|
test('init phase-op emits absolute path fields that resolve from a different process cwd', () => {
|
|
seedPhase(projectDir, '03-api', {
|
|
'03-CONTEXT.md': '# Phase Context',
|
|
'03-RESEARCH.md': '# Research',
|
|
});
|
|
fs.writeFileSync(path.join(projectDir, '.planning', 'STATE.md'), '# State\n');
|
|
fs.writeFileSync(path.join(projectDir, '.planning', 'ROADMAP.md'), '# Roadmap\n');
|
|
fs.writeFileSync(path.join(projectDir, '.planning', 'REQUIREMENTS.md'), '# Requirements\n');
|
|
|
|
const result = runGsdTools(['init', 'phase-op', '03', '--cwd', projectDir], decoyDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
for (const field of ['phase_dir', 'state_path', 'roadmap_path', 'requirements_path', 'context_path', 'research_path']) {
|
|
const value = output[field];
|
|
assert.ok(value, `${field} should be present, got: ${JSON.stringify(value)}`);
|
|
assert.ok(path.isAbsolute(value), `${field} must be absolute, got: "${value}"`);
|
|
assert.ok(fs.existsSync(value), `${field} must resolve to the real file/dir: "${value}"`);
|
|
}
|
|
});
|
|
|
|
test('init todos emits absolute pending_dir/completed_dir and per-todo path fields', () => {
|
|
const pendingDir = path.join(projectDir, '.planning', 'todos', 'pending');
|
|
fs.mkdirSync(pendingDir, { recursive: true });
|
|
fs.writeFileSync(path.join(pendingDir, 'task-1.md'), 'title: Fix bug\narea: backend\ncreated: 2026-02-25');
|
|
|
|
const result = runGsdTools(['init', 'todos', '--cwd', projectDir], decoyDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.ok(path.isAbsolute(output.pending_dir), `pending_dir must be absolute, got: "${output.pending_dir}"`);
|
|
assert.ok(fs.existsSync(output.pending_dir), `pending_dir must resolve to the real directory: "${output.pending_dir}"`);
|
|
assert.ok(path.isAbsolute(output.completed_dir), `completed_dir must be absolute, got: "${output.completed_dir}"`);
|
|
|
|
const todo = output.todos.find((t) => t.file === 'task-1.md');
|
|
assert.ok(todo, 'task-1.md should be present in todos');
|
|
assert.ok(path.isAbsolute(todo.path), `todo.path must be absolute, got: "${todo.path}"`);
|
|
assert.ok(fs.existsSync(todo.path), `todo.path must resolve to the real file: "${todo.path}"`);
|
|
});
|
|
|
|
test('init new-project emits absolute requirements_path/roadmap_path/config_path/research_dir (previously absent)', () => {
|
|
fs.writeFileSync(path.join(projectDir, '.planning', 'REQUIREMENTS.md'), '# Requirements\n');
|
|
fs.writeFileSync(path.join(projectDir, '.planning', 'ROADMAP.md'), '# Roadmap\n');
|
|
fs.writeFileSync(path.join(projectDir, '.planning', 'config.json'), '{}\n');
|
|
fs.mkdirSync(path.join(projectDir, '.planning', 'research'), { recursive: true });
|
|
|
|
const result = runGsdTools(['init', 'new-project', '--cwd', projectDir], decoyDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
for (const field of ['requirements_path', 'roadmap_path', 'config_path', 'research_dir']) {
|
|
assert.ok(field in output, `cmdInitNewProject must now emit ${field} (#2376)`);
|
|
const value = output[field];
|
|
assert.ok(path.isAbsolute(value), `${field} must be absolute, got: "${value}"`);
|
|
assert.ok(fs.existsSync(value), `${field} must resolve to the real file/dir: "${value}"`);
|
|
}
|
|
});
|
|
|
|
test('init new-milestone emits absolute requirements_path/config_path/research_dir/milestones_path (previously absent)', () => {
|
|
fs.writeFileSync(path.join(projectDir, '.planning', 'REQUIREMENTS.md'), '# Requirements\n');
|
|
fs.writeFileSync(path.join(projectDir, '.planning', 'config.json'), '{}\n');
|
|
fs.writeFileSync(path.join(projectDir, '.planning', 'MILESTONES.md'), '# Milestones\n');
|
|
fs.mkdirSync(path.join(projectDir, '.planning', 'research'), { recursive: true });
|
|
|
|
const result = runGsdTools(['init', 'new-milestone', '--cwd', projectDir], decoyDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
for (const field of ['requirements_path', 'config_path', 'research_dir', 'milestones_path']) {
|
|
assert.ok(field in output, `cmdInitNewMilestone must now emit ${field} (#2376)`);
|
|
const value = output[field];
|
|
assert.ok(path.isAbsolute(value), `${field} must be absolute, got: "${value}"`);
|
|
assert.ok(fs.existsSync(value), `${field} must resolve to the real file/dir: "${value}"`);
|
|
}
|
|
});
|
|
|
|
test('init ingest-docs emits absolute requirements_path/roadmap_path/state_path/intel_dir/conflicts_path (previously absent)', () => {
|
|
fs.writeFileSync(path.join(projectDir, '.planning', 'REQUIREMENTS.md'), '# Requirements\n');
|
|
fs.writeFileSync(path.join(projectDir, '.planning', 'ROADMAP.md'), '# Roadmap\n');
|
|
fs.writeFileSync(path.join(projectDir, '.planning', 'STATE.md'), '# State\n');
|
|
fs.mkdirSync(path.join(projectDir, '.planning', 'intel'), { recursive: true });
|
|
|
|
const result = runGsdTools(['init', 'ingest-docs', '--cwd', projectDir], decoyDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
for (const field of ['requirements_path', 'roadmap_path', 'state_path', 'intel_dir']) {
|
|
assert.ok(field in output, `cmdInitIngestDocs must now emit ${field} (#2376)`);
|
|
const value = output[field];
|
|
assert.ok(path.isAbsolute(value), `${field} must be absolute, got: "${value}"`);
|
|
assert.ok(fs.existsSync(value), `${field} must resolve to the real file/dir: "${value}"`);
|
|
}
|
|
// conflicts_path is written by the synthesizer, not present at init time —
|
|
// assert absolute-shape only, not existence.
|
|
assert.ok('conflicts_path' in output, 'cmdInitIngestDocs must now emit conflicts_path (#2376)');
|
|
assert.ok(path.isAbsolute(output.conflicts_path), `conflicts_path must be absolute, got: "${output.conflicts_path}"`);
|
|
});
|
|
|
|
test('init map-codebase emits absolute codebase_dir that resolves from a different process cwd', () => {
|
|
const codebaseDir = path.join(projectDir, '.planning', 'codebase');
|
|
fs.mkdirSync(codebaseDir, { recursive: true });
|
|
fs.writeFileSync(path.join(codebaseDir, 'STACK.md'), '# Stack');
|
|
|
|
const result = runGsdTools(['init', 'map-codebase', '--cwd', projectDir], decoyDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.ok('codebase_dir' in output, 'cmdInitMapCodebase must emit codebase_dir (#2376)');
|
|
assert.ok(path.isAbsolute(output.codebase_dir), `codebase_dir must be absolute, got: "${output.codebase_dir}"`);
|
|
assert.ok(fs.existsSync(output.codebase_dir), `codebase_dir must resolve to the real directory: "${output.codebase_dir}"`);
|
|
});
|
|
|
|
// allow-test-rule: source-text-is-the-product (see #2376)
|
|
test('gsd-core/workflows/verify-work.md plan_gap_closure step references {state_path}/{roadmap_path}, not bare .planning literals', () => {
|
|
const wfPath = path.join(__dirname, '..', 'gsd-core', 'workflows', 'verify-work.md');
|
|
const content = fs.readFileSync(wfPath, 'utf8');
|
|
// eslint-disable-next-line local/no-unbounded-quantifier -- parses this repo's own workflow .md content, fixed-size author-controlled content
|
|
const stepMatch = content.match(/<step name="plan_gap_closure">[\s\S]*?<\/step>/);
|
|
assert.ok(stepMatch, 'plan_gap_closure step should exist in verify-work.md');
|
|
const step = stepMatch[0];
|
|
assert.ok(step.includes('{state_path}'), 'plan_gap_closure must reference {state_path} from init JSON, not a bare literal');
|
|
assert.ok(step.includes('{roadmap_path}'), 'plan_gap_closure must reference {roadmap_path} from init JSON, not a bare literal');
|
|
assert.ok(!step.includes('.planning/STATE.md'), 'plan_gap_closure must not hardcode .planning/STATE.md (#2376)');
|
|
assert.ok(!step.includes('.planning/ROADMAP.md'), 'plan_gap_closure must not hardcode .planning/ROADMAP.md (#2376)');
|
|
});
|
|
|
|
// allow-test-rule: source-text-is-the-product (see #2376)
|
|
test('gsd-core/workflows/diagnose-issues.md debug agent spawn references {state_path}, not a bare .planning literal', () => {
|
|
const wfPath = path.join(__dirname, '..', 'gsd-core', 'workflows', 'diagnose-issues.md');
|
|
const content = fs.readFileSync(wfPath, 'utf8');
|
|
assert.ok(content.includes('{state_path}'), 'debug agent spawn must reference {state_path} from init JSON, not a bare literal');
|
|
assert.ok(!content.includes('.planning/STATE.md'), 'debug agent spawn must not hardcode .planning/STATE.md (#2376)');
|
|
});
|
|
|
|
// allow-test-rule: source-text-is-the-product (see #2376)
|
|
test('gsd-core/workflows/execute-phase.md verify_phase_goal step references {requirements_path}, not a bare .planning literal', () => {
|
|
const wfPath = path.join(__dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md');
|
|
const content = fs.readFileSync(wfPath, 'utf8');
|
|
// eslint-disable-next-line local/no-unbounded-quantifier -- parses this repo's own workflow .md content, fixed-size author-controlled content
|
|
const stepMatch = content.match(/<step name="verify_phase_goal">[\s\S]*?<\/step>/);
|
|
assert.ok(stepMatch, 'verify_phase_goal step should exist in execute-phase.md');
|
|
const step = stepMatch[0];
|
|
assert.ok(step.includes('{requirements_path}'), 'verify_phase_goal must reference {requirements_path} from init JSON, not a bare literal');
|
|
assert.ok(!step.includes('.planning/REQUIREMENTS.md'), 'verify_phase_goal must not hardcode .planning/REQUIREMENTS.md (#2376)');
|
|
});
|
|
|
|
// allow-test-rule: source-text-is-the-product (see #2376)
|
|
//
|
|
// Checks verbatim presence of the exact edited <required_reading>/output blocks
|
|
// rather than scanning the whole file for absence of the old literals: several
|
|
// of those literals (e.g. .planning/PROJECT.md, .planning/config.json) remain
|
|
// legitimately elsewhere in this file in orchestrator-local bash/doc-table
|
|
// text that never reaches a spawned subagent — only the three specific
|
|
// Agent(prompt=...) blocks touched by #2376 needed to change.
|
|
test('gsd-core/workflows/new-project.md synthesizer/roadmapper spawns reference {research_dir}/{project_path}/{requirements_path}/{roadmap_path}/{config_path}, not bare .planning literals', () => {
|
|
const wfPath = path.join(__dirname, '..', 'gsd-core', 'workflows', 'new-project.md');
|
|
const content = fs.readFileSync(wfPath, 'utf8');
|
|
|
|
assert.ok(content.includes(
|
|
'<required_reading>\n- {research_dir}/STACK.md\n- {research_dir}/FEATURES.md\n- {research_dir}/ARCHITECTURE.md\n- {research_dir}/PITFALLS.md\n</required_reading>'
|
|
), 'research-synthesizer spawn must read from {research_dir}, not bare .planning/research/*.md literals');
|
|
assert.ok(content.includes('Write to: {research_dir}/SUMMARY.md'),
|
|
'research-synthesizer spawn must write to {research_dir}/SUMMARY.md, not a bare literal');
|
|
|
|
assert.ok(content.includes(
|
|
'<required_reading>\n- {project_path} (Project context)\n- {requirements_path} (v1 Requirements)\n- {research_dir}/SUMMARY.md (Research findings - if exists)\n- {config_path} (Granularity and mode settings)\n</required_reading>'
|
|
), 'roadmapper spawn must read from {project_path}/{requirements_path}/{research_dir}/{config_path}, not bare .planning literals');
|
|
|
|
assert.ok(content.includes(
|
|
'<required_reading>\n - {roadmap_path} (Current roadmap to revise)\n </required_reading>'
|
|
), 'roadmapper revision spawn must read {roadmap_path}, not a bare .planning/ROADMAP.md literal');
|
|
});
|
|
|
|
// allow-test-rule: source-text-is-the-product (see #2376)
|
|
test('gsd-core/workflows/new-milestone.md synthesizer/roadmapper spawns reference {research_dir}/{project_path}/{requirements_path}/{config_path}/{milestones_path}, not bare .planning literals', () => {
|
|
const wfPath = path.join(__dirname, '..', 'gsd-core', 'workflows', 'new-milestone.md');
|
|
const content = fs.readFileSync(wfPath, 'utf8');
|
|
|
|
assert.ok(content.includes(
|
|
'<required_reading>\n- {research_dir}/STACK.md\n- {research_dir}/FEATURES.md\n- {research_dir}/ARCHITECTURE.md\n- {research_dir}/PITFALLS.md\n</required_reading>'
|
|
), 'research-synthesizer spawn must read from {research_dir}, not bare .planning/research/*.md literals');
|
|
assert.ok(content.includes('Write to: {research_dir}/SUMMARY.md'),
|
|
'research-synthesizer spawn must write to {research_dir}/SUMMARY.md, not a bare literal');
|
|
|
|
assert.ok(content.includes(
|
|
'<required_reading>\n- {project_path}\n- {requirements_path}\n- {research_dir}/SUMMARY.md (if exists)\n- {config_path}\n- {milestones_path}\n</required_reading>'
|
|
), 'roadmapper spawn must read from {project_path}/{requirements_path}/{research_dir}/{config_path}/{milestones_path}, not bare .planning literals');
|
|
});
|
|
|
|
// allow-test-rule: source-text-is-the-product (see #2376)
|
|
test('gsd-core/workflows/ingest-docs.md classifier/synthesizer/roadmapper spawns reference {intel_dir}/{conflicts_path}/{project_path}/{requirements_path}/{roadmap_path}/{state_path}, not bare .planning literals', () => {
|
|
const wfPath = path.join(__dirname, '..', 'gsd-core', 'workflows', 'ingest-docs.md');
|
|
const content = fs.readFileSync(wfPath, 'utf8');
|
|
|
|
assert.ok(content.includes('`OUTPUT_DIR` — `{intel_dir}/classifications`'),
|
|
'gsd-doc-classifier spawn OUTPUT_DIR must reference {intel_dir}, not a bare .planning/intel/classifications/ literal');
|
|
|
|
assert.ok(content.includes(
|
|
'CLASSIFICATIONS_DIR: {intel_dir}/classifications\n INTEL_DIR: {intel_dir}\n CONFLICTS_PATH: {conflicts_path}'
|
|
), 'gsd-doc-synthesizer spawn must reference {intel_dir}/{conflicts_path}, not bare .planning literals');
|
|
|
|
assert.ok(content.includes(
|
|
'Intel: {intel_dir}/SYNTHESIS.md (entry point)\n Per-type intel: {intel_dir}/decisions.md, {intel_dir}/requirements.md, {intel_dir}/constraints.md, {intel_dir}/context.md'
|
|
), 'gsd-roadmapper spawn must reference {intel_dir}, not bare .planning/intel/*.md literals');
|
|
|
|
assert.ok(content.includes(
|
|
'Produce:\n - {project_path}\n - {requirements_path}\n - {roadmap_path}\n - {state_path}'
|
|
), 'gsd-roadmapper spawn Produce block must reference absolute path fields, not bare .planning literals');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// #3057 B3: cmdInitVerifyWork surfaces an indeterminate staleness check
|
|
//
|
|
// buildPhaseCompletionProjection (init.cts) projects readVerificationStatus's
|
|
// result into phase_completion — a workflow step reads phase_completion.*
|
|
// fields directly. Pre-#3057 B3 wiring, `staleCheckIndeterminate` was
|
|
// computed by readVerificationStatus but dropped here, so a workflow could
|
|
// never distinguish "checked; nothing is stale" from "could not check".
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('#3057 B3: cmdInitVerifyWork — verification staleness-check indeterminate is surfaced', () => {
|
|
const initMod = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'init.cjs'));
|
|
let projectDir;
|
|
|
|
beforeEach(() => {
|
|
projectDir = createFixture();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(projectDir);
|
|
});
|
|
|
|
/**
|
|
* In-process capture of cmdInitVerifyWork's stdout JSON, stderr discarded.
|
|
*
|
|
* io.cts's `output()` writes via `writeAllSync` → `fs.writeSync(1, ...)`
|
|
* directly (bug #1008's non-blocking-pipe fix), NOT `process.stdout.write`
|
|
* — so mocking `process.stdout.write` here silently captures nothing and
|
|
* every assertion below saw `JSON.parse('')` ("Unexpected end of JSON
|
|
* input") regardless of what cmdInitVerifyWork actually produced. The fix
|
|
* is the fd-level seam tests/io.test.cjs already established for exactly
|
|
* this function (bug #1008's `t.mock.method(fs, 'writeSync', ...)`
|
|
* pattern): intercept fd 1, discard fd 2, and pass every OTHER fd through
|
|
* to the real writeSync — any code path that opens its own fd (e.g. a
|
|
* lock file) must still actually write, not be silently swallowed as if
|
|
* it were stdout.
|
|
*/
|
|
function captureInitVerifyWork(t, cwd, phase) {
|
|
const chunks = [];
|
|
const origWriteSync = fs.writeSync.bind(fs);
|
|
t.mock.method(fs, 'writeSync', (fd, data, offset, length) => {
|
|
if (fd === 2) return Buffer.isBuffer(data) ? data.length : String(data).length;
|
|
if (fd !== 1) return origWriteSync(fd, data, offset, length);
|
|
const chunk = Buffer.isBuffer(data)
|
|
? data.subarray(offset ?? 0, length === undefined ? data.length : (offset ?? 0) + length).toString('utf8')
|
|
: String(data);
|
|
chunks.push(chunk);
|
|
return Buffer.byteLength(chunk, 'utf8');
|
|
});
|
|
initMod.cmdInitVerifyWork(cwd, phase, false);
|
|
const captured = chunks.join('');
|
|
assert.ok(captured.length > 0, 'cmdInitVerifyWork produced no stdout output');
|
|
return captured;
|
|
}
|
|
|
|
function seedVerifiedPhase() {
|
|
seedPhase(projectDir, '03-api', {
|
|
'03-01-PLAN.md': '# Plan',
|
|
'03-01-SUMMARY.md': '# Summary',
|
|
'03-VERIFICATION.md': '---\nstatus: passed\n---\n\n# Verification\n',
|
|
});
|
|
fs.writeFileSync(path.join(projectDir, '.planning', 'STATE.md'), '# State\n');
|
|
fs.writeFileSync(path.join(projectDir, '.planning', 'ROADMAP.md'), '# Roadmap\n');
|
|
const phaseDir = path.join(projectDir, '.planning', 'phases', '03-api');
|
|
const summaryPath = path.join(phaseDir, '03-01-SUMMARY.md');
|
|
const verificationPath = path.join(phaseDir, '03-VERIFICATION.md');
|
|
// Deterministic mtime ordering (never rely on write-order clock ties):
|
|
// verification strictly newer than the summary → a completed check finds
|
|
// nothing stale.
|
|
const older = new Date('2026-01-01T00:00:00.000Z');
|
|
const newer = new Date('2026-01-01T00:01:00.000Z');
|
|
fs.utimesSync(summaryPath, older, older);
|
|
fs.utimesSync(verificationPath, newer, newer);
|
|
return { summaryPath, verificationPath };
|
|
}
|
|
|
|
test('an fs failure inside the staleness check sets phase_completion.verification_stale_check_indeterminate:true', (t) => {
|
|
const { summaryPath, verificationPath } = seedVerifiedPhase();
|
|
const origStatSync = fs.statSync;
|
|
|
|
t.mock.method(fs, 'statSync', function injectedStaleCheckFault(target, ...args) {
|
|
const targetPath = String(target);
|
|
if (targetPath === verificationPath || targetPath === summaryPath) {
|
|
throw new Error('injected stat failure (#3057 B3)');
|
|
}
|
|
return origStatSync.call(fs, target, ...args);
|
|
});
|
|
|
|
const output = JSON.parse(captureInitVerifyWork(t, projectDir, '03'));
|
|
|
|
// Pre-existing no-throw fail-open routing is UNCHANGED: status still
|
|
// resolves to 'passed' exactly as it would without the injected fault.
|
|
assert.strictEqual(output.phase_completion.verification_status, 'passed');
|
|
assert.strictEqual(output.phase_completion.verification_stale_check_indeterminate, true);
|
|
});
|
|
|
|
test('a completed staleness check that finds nothing stale reports verification_stale_check_indeterminate:false', (t) => {
|
|
seedVerifiedPhase();
|
|
|
|
const output = JSON.parse(captureInitVerifyWork(t, projectDir, '03'));
|
|
|
|
assert.strictEqual(output.phase_completion.verification_status, 'passed');
|
|
assert.strictEqual(output.phase_completion.verification_stale_check_indeterminate, false);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// roadmap analyze command
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Folded from tests/bug-3491-nested-git-worktree.test.cjs — consolidation epic #1969 (B6 #1975)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { describe: __foldDescribe } = require('node:test');
|
|
__foldDescribe("folded:bug-3491-nested-git-worktree (consolidation epic #1969 B6 #1975)", () => {
|
|
// allow-test-rule: source-text-is-the-product (see #3491)
|
|
// Bug #3491 — new-project workflow creates nested .git in subdirectory when
|
|
// parent already has git repo.
|
|
//
|
|
// The workflow's `has_git` boolean was derived from `pathExists(cwd, '.git')`
|
|
// — a shallow check that only sees a `.git` entry directly in the current
|
|
// directory. Subdirectories of an existing git worktree therefore reported
|
|
// `has_git: false`, causing the workflow's `git init` step to create a nested
|
|
// `.git` inside the outer repo's worktree. Subsequent gsd-sdk commits then
|
|
// targeted the nested repo instead of the outer one, silently dropping all
|
|
// planning artefacts from the outer repo's history.
|
|
//
|
|
// This test asserts the corrected semantics, mirroring `git rev-parse
|
|
// --is-inside-work-tree`:
|
|
//
|
|
// - `has_git: true` is reported whenever the cwd is inside a git worktree,
|
|
// even when no `.git` entry is in cwd itself.
|
|
// - The init payload surfaces `git_worktree_root` and `in_nested_subdir` so
|
|
// the workflow can warn the user and skip `git init`.
|
|
// - The workflow markdown's `git init` step is gated on
|
|
// `in_nested_subdir: false`, never unconditional under `has_git: false`.
|
|
|
|
'use strict';
|
|
|
|
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const { runGsdTools, cleanup } = require('./helpers.cjs');
|
|
const { gitOrThrow } = require('./helpers/git-fixture.cjs');
|
|
|
|
const WORKFLOW_PATH = path.join(
|
|
__dirname,
|
|
'..',
|
|
'gsd-core',
|
|
'workflows',
|
|
'new-project.md',
|
|
);
|
|
|
|
// ─── Helper: create outer git repo with a nested workstream subdir ─────────
|
|
|
|
// On Windows the runtime emits forward slashes (git's convention) while
|
|
// path.join produces backslashes — normalize both sides via the shared
|
|
// toPosixPath helper before any equality comparison.
|
|
const { toPosixPath: normalizePath } = require('./helpers.cjs');
|
|
|
|
function createOuterRepoWithSubdir(prefix = 'bug-3491-') {
|
|
const outer = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
// macOS /tmp -> /private/tmp; on Windows the runner's %TEMP% is the 8.3
|
|
// short-name (RUNNER~1) and the runtime resolves to the long form.
|
|
// realpathSync.native handles both; then normalize separators for compare.
|
|
const outerReal = fs.realpathSync.native(outer);
|
|
gitOrThrow(['init'], { cwd: outerReal });
|
|
gitOrThrow(['config', 'user.email', 'test@test.com'], { cwd: outerReal });
|
|
gitOrThrow(['config', 'user.name', 'Test'], { cwd: outerReal });
|
|
gitOrThrow(['config', 'commit.gpgsign', 'false'], { cwd: outerReal });
|
|
fs.writeFileSync(path.join(outerReal, 'README.md'), '# outer\n');
|
|
gitOrThrow(['add', '-A'], { cwd: outerReal });
|
|
gitOrThrow(['commit', '-m', 'initial'], { cwd: outerReal });
|
|
|
|
const subdir = path.join(outerReal, 'workstreams', 'my-project');
|
|
fs.mkdirSync(subdir, { recursive: true });
|
|
return { outer: outerReal, subdir };
|
|
}
|
|
|
|
// ─── Behavioural tests against the live `init new-project` handler ─────────
|
|
|
|
test('bug-3491: init new-project reports has_git: true inside parent git worktree', () => {
|
|
const { outer, subdir } = createOuterRepoWithSubdir();
|
|
try {
|
|
const result = runGsdTools('init new-project', subdir);
|
|
assert.ok(result.success, `init new-project failed: ${result.error}`);
|
|
|
|
const payload = JSON.parse(result.output);
|
|
|
|
// Core fix: shallow `.git in cwd` check was wrong — we are inside the
|
|
// outer worktree, so the workflow MUST see has_git: true.
|
|
assert.strictEqual(
|
|
payload.has_git,
|
|
true,
|
|
'expected has_git=true when cwd is inside an existing git worktree (parent .git)',
|
|
);
|
|
|
|
// The workflow needs the worktree root and a nesting flag to decide
|
|
// whether to skip `git init` and emit a friendly warning.
|
|
assert.strictEqual(
|
|
normalizePath(payload.git_worktree_root),
|
|
normalizePath(outer),
|
|
`expected git_worktree_root to be the outer repo (${outer}), got: ${payload.git_worktree_root}`,
|
|
);
|
|
assert.strictEqual(
|
|
payload.in_nested_subdir,
|
|
true,
|
|
'expected in_nested_subdir=true when cwd is a subdirectory of the worktree root',
|
|
);
|
|
} finally {
|
|
cleanup(outer);
|
|
}
|
|
});
|
|
|
|
test('bug-3491: init new-project reports has_git: true at worktree root with in_nested_subdir: false', () => {
|
|
const { outer } = createOuterRepoWithSubdir();
|
|
try {
|
|
const result = runGsdTools('init new-project', outer);
|
|
assert.ok(result.success, `init new-project failed: ${result.error}`);
|
|
|
|
const payload = JSON.parse(result.output);
|
|
assert.strictEqual(payload.has_git, true, 'has_git must be true at the worktree root');
|
|
assert.strictEqual(normalizePath(payload.git_worktree_root), normalizePath(outer));
|
|
assert.strictEqual(
|
|
payload.in_nested_subdir,
|
|
false,
|
|
'at the worktree root, in_nested_subdir must be false',
|
|
);
|
|
} finally {
|
|
cleanup(outer);
|
|
}
|
|
});
|
|
|
|
test('bug-3491: init new-project reports has_git: false outside any git worktree', () => {
|
|
const tmp = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'bug-3491-bare-')));
|
|
try {
|
|
const result = runGsdTools('init new-project', tmp);
|
|
assert.ok(result.success, `init new-project failed: ${result.error}`);
|
|
const payload = JSON.parse(result.output);
|
|
assert.strictEqual(payload.has_git, false);
|
|
assert.strictEqual(payload.in_nested_subdir, false);
|
|
assert.strictEqual(payload.git_worktree_root, null);
|
|
} finally {
|
|
cleanup(tmp);
|
|
}
|
|
});
|
|
|
|
test('bug-3491: init ingest-docs mirrors the same has_git semantics', () => {
|
|
// ingest-docs.md has the same shallow check and the same nested-init risk.
|
|
const { outer, subdir } = createOuterRepoWithSubdir('bug-3491-ingest-');
|
|
try {
|
|
const result = runGsdTools('init ingest-docs', subdir);
|
|
assert.ok(result.success, `init ingest-docs failed: ${result.error}`);
|
|
const payload = JSON.parse(result.output);
|
|
assert.strictEqual(
|
|
payload.has_git,
|
|
true,
|
|
'init ingest-docs must also detect parent worktree (#3491 related path)',
|
|
);
|
|
assert.strictEqual(normalizePath(payload.git_worktree_root), normalizePath(outer));
|
|
assert.strictEqual(payload.in_nested_subdir, true);
|
|
} finally {
|
|
cleanup(outer);
|
|
}
|
|
});
|
|
|
|
// ─── Workflow-text test: the deployed `new-project.md` must gate `git init` ─
|
|
|
|
test('bug-3491: new-project.md gates `git init` on in_nested_subdir, not just has_git', () => {
|
|
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
|
|
|
// The pre-fix workflow had the literal sequence:
|
|
//
|
|
// **If `has_git` is false:** Initialize git:
|
|
// ```bash
|
|
// git init
|
|
// ```
|
|
//
|
|
// …which fires for any subdirectory of an existing repo. The fix must
|
|
// either gate the init on `in_nested_subdir`/worktree-root semantics or
|
|
// drop the unconditional `git init` block entirely.
|
|
const unconditionalInitPattern =
|
|
/\*\*If `has_git` is false:\*\* Initialize git:\s*\r?\n+```bash\s*\r?\ngit init\s*\r?\n```/;
|
|
assert.ok(
|
|
!unconditionalInitPattern.test(content),
|
|
'new-project.md must not run `git init` unconditionally on has_git=false (#3491). ' +
|
|
'Gate it on `in_nested_subdir === false` so the workflow refuses to create ' +
|
|
'a nested .git inside an existing worktree.',
|
|
);
|
|
|
|
// The fixed workflow MUST mention the new field so reviewers can see the
|
|
// gating exists. (Workflow markdown IS the deployed product — testing it
|
|
// as text is the only end-to-end signal we have.)
|
|
assert.ok(
|
|
/in_nested_subdir/.test(content),
|
|
'new-project.md must reference `in_nested_subdir` after the #3491 fix',
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Folded from tests/init-section-manifest.test.cjs — #2932 (epic #1671 Phase 5)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
//
|
|
// init CLI negative matrix for `section_manifest`. Covers
|
|
// `.gsd/phase/chore-2932-init-section-manifest/50-test-matrix.md` section E
|
|
// (rows 42-59) plus row 62. Drives the REAL CLI through the dispatch seam
|
|
// (`spawnSync(process.execPath, [...])` with argv ARRAYS — never shell strings) so
|
|
// hostile inputs (rows 55/56) prove no shell interpolation and no path escape.
|
|
//
|
|
// Each test asserts: exit status, structured JSON result, absence of project-tree
|
|
// fs mutation, and no stack trace in non-debug stderr — never substring-matching
|
|
// rendered prose (local/no-source-grep).
|
|
|
|
describe('init section manifest', () => {
|
|
const GSD_ROOT = path.join(__dirname, '..', 'gsd-core');
|
|
const COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
|
|
|
|
// ── Drivers ─────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Invokes the real CLI dispatch seam with an argv ARRAY (never a shell string),
|
|
* so shell metacharacters in an argument (rows 55/56) can never be interpreted
|
|
* by a shell — spawnSync with an array bypasses the shell entirely. Always runs
|
|
* with GSD_JSON_ERRORS=1 so an error path yields a typed `{ ok, reason, message }`
|
|
* envelope instead of prose, per CONTRIBUTING.md "Prohibited: Raw Text Matching".
|
|
*/
|
|
function runSectionManifestCli(args, cwd, env = {}) {
|
|
const result = spawnSync(process.execPath, [TOOLS_PATH, 'query', ...args], {
|
|
cwd,
|
|
encoding: 'utf8',
|
|
env: { ...process.env, GSD_JSON_ERRORS: '1', ...env },
|
|
timeout: 30000,
|
|
});
|
|
let stdout = result.stdout || '';
|
|
// output() spills payloads over 50KB to a tmpfile and prints "@file:<path>"
|
|
// (src/io.cts) — dereference it exactly as the workflow itself does.
|
|
if (stdout.startsWith('@file:')) {
|
|
stdout = fs.readFileSync(stdout.slice('@file:'.length).trim(), 'utf8');
|
|
}
|
|
return { status: result.status, stdout, stderr: result.stderr || '' };
|
|
}
|
|
|
|
function runExecutePhase(phaseArgs, cwd, env = {}) {
|
|
return runSectionManifestCli(['init.execute-phase', ...phaseArgs], cwd, env);
|
|
}
|
|
|
|
function parseOkJson(result, label) {
|
|
assert.equal(result.status, 0, `${label}: expected exit 0, got ${result.status} (stderr: ${result.stderr})`);
|
|
assertNoStackTrace(result.stderr, label);
|
|
return JSON.parse(result.stdout);
|
|
}
|
|
|
|
function parseErrorJson(result, label) {
|
|
assert.notEqual(result.status, 0, `${label}: expected non-zero exit`);
|
|
assertNoStackTrace(result.stderr, label);
|
|
return JSON.parse(result.stderr);
|
|
}
|
|
|
|
/** Node stack-trace frames look like "\n at fn (file:line:col)" — a structural
|
|
* signal, not a content match on any file's prose. */
|
|
function assertNoStackTrace(text, label) {
|
|
assert.ok(!/\n\s+at\s+\S+/.test(text || ''), `${label}: unexpected stack trace: ${text}`);
|
|
}
|
|
|
|
/** Recursive, sorted snapshot of a directory's structure — name/size/mtime triples,
|
|
* used to assert a read-only `query` command mutates nothing under the project tree. */
|
|
function snapshotSectionManifestTree(dir) {
|
|
const out = [];
|
|
function walk(d, rel) {
|
|
let entries;
|
|
try {
|
|
entries = fs.readdirSync(d, { withFileTypes: true });
|
|
} catch {
|
|
return;
|
|
}
|
|
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
for (const entry of entries) {
|
|
const relPath = rel ? `${rel}/${entry.name}` : entry.name;
|
|
const full = path.join(d, entry.name);
|
|
if (entry.isDirectory()) {
|
|
out.push(`D:${relPath}`);
|
|
walk(full, relPath);
|
|
} else {
|
|
const st = fs.statSync(full);
|
|
out.push(`F:${relPath}:${st.size}`);
|
|
}
|
|
}
|
|
}
|
|
walk(dir, '');
|
|
return out;
|
|
}
|
|
|
|
function seedSinglePhaseProject(t, prefix) {
|
|
const dir = createTempProject(prefix);
|
|
t.after(() => cleanup(dir));
|
|
seedPhase(dir, '01-widgets', {});
|
|
return dir;
|
|
}
|
|
|
|
// ── E42-43: baseline manifest emission, with/without --wave ─────────────
|
|
|
|
describe('init execute-phase: section_manifest emission (#2932)', () => {
|
|
test('emitsSectionManifestWithoutWaveFlag', (t) => {
|
|
const dir = seedSinglePhaseProject(t, 'gsd-e42-');
|
|
const before = snapshotSectionManifestTree(dir);
|
|
const body = parseOkJson(runExecutePhase(['1'], dir), 'no-flag');
|
|
assert.deepStrictEqual(snapshotSectionManifestTree(dir), before, 'query command must not mutate the project tree');
|
|
|
|
assert.ok(body.section_manifest, 'section_manifest must be present');
|
|
assert.equal(body.section_manifest.workflow, 'execute-phase');
|
|
assert.ok(!body.section_manifest.included.includes('partial-wave'), 'partial-wave must be excluded without --wave');
|
|
assert.ok(body.section_manifest.excluded.includes('partial-wave'));
|
|
assert.deepStrictEqual(body.section_manifest.read, []);
|
|
});
|
|
|
|
test('emitsSectionManifestIncludingPartialWaveWithWaveFlag (#2932 headline acceptance criterion)', (t) => {
|
|
const dir = seedSinglePhaseProject(t, 'gsd-e43-');
|
|
const body = parseOkJson(runExecutePhase(['1', '--wave', '1'], dir), 'with-wave');
|
|
|
|
assert.ok(body.section_manifest);
|
|
assert.deepStrictEqual(body.section_manifest.included, ['partial-wave']);
|
|
assert.ok(!body.section_manifest.excluded.includes('partial-wave'));
|
|
assert.deepStrictEqual(body.section_manifest.read, [
|
|
'gsd-core/workflows/execute-phase/steps/partial-wave.md',
|
|
]);
|
|
});
|
|
|
|
test('#3511-class: regression-gate (state:has-prior-phases) ignores a misplaced VERIFICATION.md that belongs to another phase', (t) => {
|
|
const dir = createTempProject('gsd-3511-hasprior-');
|
|
t.after(() => cleanup(dir));
|
|
// Phase 01 is the one being executed.
|
|
seedPhase(dir, '01-widgets', {});
|
|
// Phase 02's directory holds ONLY a stray artifact whose filename
|
|
// token ("05-") belongs to phase 05, not to this directory's own
|
|
// phase (02) — it must not make phase 02 look like it has its own
|
|
// verification report.
|
|
seedPhase(dir, '02-other', { '05-VERIFICATION.md': '# Verification for phase 05' });
|
|
|
|
const body = parseOkJson(runExecutePhase(['1'], dir), 'stray-verification');
|
|
|
|
assert.ok(body.section_manifest, 'section_manifest must be present');
|
|
assert.ok(!body.section_manifest.included.includes('regression-gate'),
|
|
'regression-gate must not be included when no OTHER phase has its own verification');
|
|
assert.ok(body.section_manifest.excluded.includes('regression-gate'));
|
|
});
|
|
});
|
|
|
|
// ── E44-46: phase argument boundary ────────────────────────────────────
|
|
|
|
describe('init execute-phase: phase argument boundary (#2932)', () => {
|
|
test('failsWhenPhaseArgumentMissing', (t) => {
|
|
const dir = seedSinglePhaseProject(t, 'gsd-e44-');
|
|
const err = parseErrorJson(runExecutePhase([], dir), 'missing-phase');
|
|
assert.equal(err.ok, false);
|
|
assert.equal(typeof err.reason, 'string');
|
|
assert.equal(typeof err.message, 'string');
|
|
});
|
|
|
|
test('failsOnEmptyPhaseArgument', (t) => {
|
|
const dir = seedSinglePhaseProject(t, 'gsd-e45-');
|
|
const err = parseErrorJson(runExecutePhase([''], dir), 'empty-phase');
|
|
assert.equal(err.ok, false);
|
|
});
|
|
|
|
test('failsOnWhitespaceOnlyPhaseArgument', (t) => {
|
|
// Test-matrix row 46 names "non-zero" as expected. Empirically, cmdInitExecutePhase's
|
|
// guard is `if (!phase)`, which is false for a non-empty whitespace string — this
|
|
// pre-existing idiom is SHARED by every phase-taking init subcommand (plan-phase,
|
|
// todos, phase-op, ...), not introduced by #2932, and changing it here would be a
|
|
// blast-radius violation of the CRITICAL routeInitCommand rating (37 affected files).
|
|
// " " instead falls through to guardedFindPhase, which returns no match — the same
|
|
// graceful "not found" degrade the path-traversal/shell-metachar rows (55/56) require,
|
|
// not a crash. This test locks the REAL, current, exit-0 "not found" behavior rather
|
|
// than the matrix's a-priori assumption; see the dispatch report for this reconciliation.
|
|
const dir = seedSinglePhaseProject(t, 'gsd-e46-');
|
|
const body = parseOkJson(runExecutePhase([' '], dir), 'whitespace-phase');
|
|
assert.equal(body.phase_found, false);
|
|
assert.ok(body.section_manifest, 'manifest is still emitted — it does not depend on phase_found');
|
|
});
|
|
});
|
|
|
|
// ── E47-52: --wave token-presence semantics ──────────────────────────────
|
|
|
|
describe('init execute-phase: --wave token-presence semantics (#2932)', () => {
|
|
test('treatsValuelessWaveFlagAsPresent', (t) => {
|
|
const dir = seedSinglePhaseProject(t, 'gsd-e47-');
|
|
const body = parseOkJson(runExecutePhase(['1', '--wave'], dir), 'valueless-wave');
|
|
assert.deepStrictEqual(body.section_manifest.included, ['partial-wave']);
|
|
});
|
|
|
|
test('treatsWaveZeroAsPresent', (t) => {
|
|
const dir = seedSinglePhaseProject(t, 'gsd-e48-');
|
|
const body = parseOkJson(runExecutePhase(['1', '--wave', '0'], dir), 'wave-zero');
|
|
assert.deepStrictEqual(body.section_manifest.included, ['partial-wave']);
|
|
});
|
|
|
|
test('treatsDuplicateWaveFlagsIdempotently', (t) => {
|
|
const dir = seedSinglePhaseProject(t, 'gsd-e49-');
|
|
const body = parseOkJson(runExecutePhase(['1', '--wave', '1', '--wave', '2'], dir), 'dup-wave');
|
|
assert.deepStrictEqual(body.section_manifest.included, ['partial-wave']);
|
|
});
|
|
|
|
test('handlesMalformedWaveAssignments', (t) => {
|
|
// Corrected after the first full verification run: neither --wave= nor
|
|
// --wave==1 is a documented or shipped token (commands/gsd/execute-phase.md,
|
|
// gsd-core/workflows/execute-phase.md, and the docs tree all only ever
|
|
// emit the space-separated --wave N form) — each is an exact, distinct,
|
|
// undeclared flag token, so ADR-3473 §8.4 mandates rejecting it outright
|
|
// rather than silently letting it fall through unrecognized. Exit 1, and
|
|
// — same exact-match discipline that keeps "--waves"/"--wave-filter"
|
|
// from false-activating (row 52) — the rejection must name the
|
|
// malformed token itself, proving it was never coerced into activating
|
|
// --wave.
|
|
const dir = seedSinglePhaseProject(t, 'gsd-e50-');
|
|
for (const token of ['--wave=', '--wave==1']) {
|
|
const result = runExecutePhase(['1', token], dir);
|
|
assert.equal(result.status, 1, `malformed-wave:${token}: expected exit 1, got ${result.status}`);
|
|
const err = JSON.parse(result.stderr);
|
|
assert.match(err.message, new RegExp(escapeRegex(token)), `"${token}" must be named as the unknown flag, proving it did not activate --wave`);
|
|
}
|
|
});
|
|
|
|
test('doesNotConsumeFollowingFlagAsWaveValue', (t) => {
|
|
// Unit-level: --wave is an optionalValueFlags entry (#2932's `--wave N`
|
|
// shape) — its cursor never swallows a following flag-shaped token as
|
|
// its value; it advances by 1, not 2, leaving --weird for its own
|
|
// validation. Assert the extraction directly rather than through the
|
|
// full CLI, since --weird's own (correct) rejection below makes the
|
|
// manifest body unreachable.
|
|
const { parseNamedArgs } = require('../gsd-core/bin/lib/command-arg-projection.cjs');
|
|
const extracted = parseNamedArgs(['--wave', '--weird'], { optionalValueFlags: ['wave'], positionals: 'rest' });
|
|
assert.strictEqual(extracted.ok, true);
|
|
assert.strictEqual(extracted.data.wave, true, '--wave must resolve to present (true), not be starved by the following token');
|
|
|
|
// Integration: --weird is a genuinely undeclared flag on execute-phase,
|
|
// so ADR-3473 §8.4 mandates rejecting it — exit 1, not the old exit-0
|
|
// "ignored" shape. The rejection naming "--weird" (not "--wave") is
|
|
// itself proof --wave did not consume it as a value.
|
|
const dir = seedSinglePhaseProject(t, 'gsd-e51-');
|
|
const result = runExecutePhase(['1', '--wave', '--weird'], dir);
|
|
assert.equal(result.status, 1, `wave-then-weird: expected exit 1, got ${result.status}`);
|
|
const err = JSON.parse(result.stderr);
|
|
assert.match(err.message, /--weird/, 'the unknown-flag rejection must name --weird, proving --wave did not consume it as its value');
|
|
});
|
|
|
|
test('nearMissFlagNamesDoNotActivateWave', (t) => {
|
|
// Corrected after the first full verification run: neither "--waves"
|
|
// nor "--wave-filter" is documented or shipped for execute-phase, so
|
|
// each is a genuinely undeclared flag — ADR-3473 §8.4 mandates
|
|
// rejecting it (exit 1), not silently ignoring it. The rejection
|
|
// naming the near-miss token itself is what proves it never
|
|
// false-activated --wave.
|
|
const dir = seedSinglePhaseProject(t, 'gsd-e52-');
|
|
for (const flag of ['--waves', '--wave-filter']) {
|
|
const result = runExecutePhase(['1', flag], dir);
|
|
assert.equal(result.status, 1, `near-miss:${flag}: expected exit 1, got ${result.status}`);
|
|
const err = JSON.parse(result.stderr);
|
|
assert.match(err.message, new RegExp(escapeRegex(flag)), `"${flag}" must be named as the unknown flag, proving it did not activate --wave`);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── E53: unknown subcommand ───────────────────────────────────────────────
|
|
|
|
describe('init dispatch: unknown subcommand (#2932 row 53)', () => {
|
|
test('reportsUnknownInitSubcommand', (t) => {
|
|
const dir = seedSinglePhaseProject(t, 'gsd-e53-');
|
|
const err = parseErrorJson(runSectionManifestCli(['init.frobnicate'], dir), 'unknown-subcommand');
|
|
assert.equal(err.ok, false);
|
|
assert.equal(err.reason, 'sdk_unknown_command');
|
|
});
|
|
});
|
|
|
|
// ── E54-56: hostile inputs ────────────────────────────────────────────────
|
|
|
|
describe('init execute-phase: hostile inputs (#2932)', () => {
|
|
test('handlesVeryLongAndUnicodeValues', (t) => {
|
|
const dir = seedSinglePhaseProject(t, 'gsd-e54-');
|
|
const longUnicodeValue = 'x'.repeat(20000) + '你好\u{1f600}';
|
|
const result = runExecutePhase(['1', '--wave', longUnicodeValue], dir);
|
|
assert.equal(result.status, 0, `expected exit 0, got ${result.status} (stderr: ${result.stderr})`);
|
|
assertNoStackTrace(result.stderr, 'long-unicode-value');
|
|
const body = JSON.parse(result.stdout);
|
|
assert.deepStrictEqual(body.section_manifest.included, ['partial-wave']);
|
|
|
|
const longPhaseResult = runExecutePhase(['1' + 'z'.repeat(20000)], dir);
|
|
assert.equal(longPhaseResult.status, 0);
|
|
assertNoStackTrace(longPhaseResult.stderr, 'long-phase-value');
|
|
});
|
|
|
|
test('doesNotInterpolateShellMetacharactersInPhase', (t) => {
|
|
const dir = seedSinglePhaseProject(t, 'gsd-e55-');
|
|
const sentinel = path.join(dir, 'pwned-sentinel');
|
|
const hostilePhase = `1; touch ${sentinel}; $(touch ${sentinel}) \`touch ${sentinel}\` && touch ${sentinel} || touch ${sentinel}`;
|
|
const result = runExecutePhase([hostilePhase], dir);
|
|
assert.equal(result.status, 0, `expected exit 0 (no shell execution), got ${result.status}`);
|
|
assertNoStackTrace(result.stderr, 'shell-metachars');
|
|
assert.ok(!fs.existsSync(sentinel), 'shell metacharacters in the phase argument must never be interpreted — argv array bypasses the shell entirely');
|
|
});
|
|
|
|
test('rejectsPathTraversalStylePhaseValue', (t) => {
|
|
const dir = seedSinglePhaseProject(t, 'gsd-e56-');
|
|
const before = snapshotSectionManifestTree(dir);
|
|
const body = parseOkJson(runExecutePhase(['../../../../etc/passwd'], dir), 'path-traversal');
|
|
assert.deepStrictEqual(snapshotSectionManifestTree(dir), before, 'no fs mutation from a traversal-shaped phase value');
|
|
assert.equal(body.phase_found, false, 'a traversal-shaped value must never resolve to a real phase');
|
|
assert.ok(
|
|
!String(body.phase_dir || '').includes('etc/passwd'),
|
|
'phase_dir must never resolve outside the project tree',
|
|
);
|
|
});
|
|
});
|
|
|
|
// ── E57-58: degraded path — manifest artifact missing/malformed ─────────
|
|
|
|
describe('init execute-phase: section_manifest degrades to null (#2932)', () => {
|
|
test('degradesToNullManifestWhenArtifactMissing', (t) => {
|
|
const dir = seedSinglePhaseProject(t, 'gsd-e57-');
|
|
const missingPath = path.join(dir, 'does-not-exist-section-manifest.json');
|
|
const result = runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: missingPath });
|
|
assert.equal(result.status, 0, `expected exit 0 despite missing manifest artifact, got ${result.status} (stderr: ${result.stderr})`);
|
|
assertNoStackTrace(result.stderr, 'manifest-missing');
|
|
const body = JSON.parse(result.stdout);
|
|
assert.equal(body.section_manifest, null);
|
|
});
|
|
|
|
test('degradesToNullManifestWhenArtifactMalformed', (t) => {
|
|
const dir = seedSinglePhaseProject(t, 'gsd-e58-');
|
|
const badPath = path.join(dir, 'bad-section-manifest.json');
|
|
fs.writeFileSync(badPath, '{ this is not valid json');
|
|
const result = runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: badPath });
|
|
assert.equal(result.status, 0, `expected exit 0 despite malformed manifest artifact, got ${result.status} (stderr: ${result.stderr})`);
|
|
assertNoStackTrace(result.stderr, 'manifest-malformed');
|
|
const body = JSON.parse(result.stdout);
|
|
assert.equal(body.section_manifest, null);
|
|
});
|
|
|
|
test('degradesToNullManifestWhenArtifactWrongShape', (t) => {
|
|
// Extends row 58: valid JSON, wrong shape (design doc + init.cts loadSectionManifestSections
|
|
// both name this as a degraded case distinct from "not JSON at all").
|
|
const dir = seedSinglePhaseProject(t, 'gsd-e58b-');
|
|
const wrongShapePath = path.join(dir, 'wrong-shape-section-manifest.json');
|
|
fs.writeFileSync(wrongShapePath, JSON.stringify([1, 2, 3]));
|
|
const result = runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: wrongShapePath });
|
|
assert.equal(result.status, 0);
|
|
const body = JSON.parse(result.stdout);
|
|
assert.equal(body.section_manifest, null);
|
|
});
|
|
|
|
// ── C4 (#2992): stale FLAT pre-6.1 artifact must degrade to null and
|
|
// never be mis-attributed to any workflow — the upgrade-path row.
|
|
|
|
test('staleFlatPreWideningArtifactDegradesToNull (row C4)', (t) => {
|
|
const dir = seedSinglePhaseProject(t, 'gsd-c4-');
|
|
const flatPath = path.join(dir, 'flat-section-manifest.json');
|
|
// The pre-#2932-Phase-6.1 shape: a single top-level `sections` array,
|
|
// no `workflows` key at all. It even NAMES an execute-phase-shaped
|
|
// section, so a mis-attribution bug would silently "work".
|
|
fs.writeFileSync(
|
|
flatPath,
|
|
JSON.stringify({
|
|
sections: [{ id: 'partial-wave', when: 'flag:--wave', read: 'gsd-core/workflows/execute-phase/steps/partial-wave.md' }],
|
|
}),
|
|
);
|
|
const result = runExecutePhase(['1', '--wave', '1'], dir, { GSD_SECTION_MANIFEST: flatPath });
|
|
assert.equal(result.status, 0, `expected exit 0 despite stale flat artifact, got ${result.status} (stderr: ${result.stderr})`);
|
|
assertNoStackTrace(result.stderr, 'stale-flat-artifact');
|
|
const body = JSON.parse(result.stdout);
|
|
assert.equal(body.section_manifest, null, 'a pre-6.1 flat artifact must never be mis-parsed as some workflow\'s sections');
|
|
});
|
|
|
|
// ── C8 (#2992): valid JSON that is not an object — one row each.
|
|
|
|
test('nonObjectJsonArtifactDegradesToNull (row C8)', (t) => {
|
|
const dir = seedSinglePhaseProject(t, 'gsd-c8-');
|
|
for (const hostileJson of ['0', '"str"', '[]', 'null', 'true']) {
|
|
const hostilePath = path.join(dir, `hostile-${Buffer.from(hostileJson).toString('hex')}-section-manifest.json`);
|
|
fs.writeFileSync(hostilePath, hostileJson);
|
|
const result = runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: hostilePath });
|
|
assert.equal(result.status, 0, `hostile JSON ${hostileJson}: expected exit 0 (stderr: ${result.stderr})`);
|
|
assertNoStackTrace(result.stderr, `hostile-json:${hostileJson}`);
|
|
const body = JSON.parse(result.stdout);
|
|
assert.equal(body.section_manifest, null, `hostile JSON ${hostileJson} must degrade to null`);
|
|
}
|
|
});
|
|
|
|
// ── C9 (#2992): file present but 0 bytes.
|
|
|
|
test('emptyZeroByteArtifactFileDegradesToNull (row C9)', (t) => {
|
|
const dir = seedSinglePhaseProject(t, 'gsd-c9-');
|
|
const emptyPath = path.join(dir, 'empty-section-manifest.json');
|
|
fs.writeFileSync(emptyPath, '');
|
|
assert.equal(fs.statSync(emptyPath).size, 0, 'sanity: fixture file must be 0 bytes');
|
|
const result = runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: emptyPath });
|
|
assert.equal(result.status, 0, `expected exit 0 despite empty artifact file, got ${result.status} (stderr: ${result.stderr})`);
|
|
assertNoStackTrace(result.stderr, 'empty-artifact-file');
|
|
const body = JSON.parse(result.stdout);
|
|
assert.equal(body.section_manifest, null);
|
|
});
|
|
|
|
// ── #2992 review finding: an unsafe `read` path degrades the WHOLE
|
|
// load to null, exactly like every other shape violation above — the
|
|
// field is documented as a POSIX-normalized, repo-root-RELATIVE path,
|
|
// so an absolute path, a Windows drive/UNC prefix, or a `..` traversal
|
|
// segment must never be trusted through to a later `fs.readFileSync`.
|
|
|
|
test('degradesToNullWhenReadPathIsAbsolute', (t) => {
|
|
const dir = seedSinglePhaseProject(t, 'gsd-unsafe-abs-');
|
|
const manifestPath = path.join(dir, 'unsafe-abs-section-manifest.json');
|
|
fs.writeFileSync(manifestPath, JSON.stringify({
|
|
workflows: { 'execute-phase': [{ id: 'x', when: 'always', read: '/etc/passwd' }] },
|
|
}));
|
|
const result = runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: manifestPath });
|
|
assert.equal(result.status, 0, `expected exit 0 despite an absolute read path, got ${result.status} (stderr: ${result.stderr})`);
|
|
assertNoStackTrace(result.stderr, 'unsafe-read-absolute');
|
|
const body = JSON.parse(result.stdout);
|
|
assert.equal(body.section_manifest, null, 'an absolute `read` path must degrade the whole load to null');
|
|
});
|
|
|
|
test('degradesToNullWhenReadPathContainsDotDotTraversal', (t) => {
|
|
const dir = seedSinglePhaseProject(t, 'gsd-unsafe-dotdot-');
|
|
const manifestPath = path.join(dir, 'unsafe-dotdot-section-manifest.json');
|
|
fs.writeFileSync(manifestPath, JSON.stringify({
|
|
workflows: { 'execute-phase': [{ id: 'x', when: 'always', read: '../../etc/passwd' }] },
|
|
}));
|
|
const result = runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: manifestPath });
|
|
assert.equal(result.status, 0, `expected exit 0 despite a ../ traversal read path, got ${result.status} (stderr: ${result.stderr})`);
|
|
assertNoStackTrace(result.stderr, 'unsafe-read-dotdot');
|
|
const body = JSON.parse(result.stdout);
|
|
assert.equal(body.section_manifest, null, 'a `..` traversal segment in `read` must degrade the whole load to null');
|
|
});
|
|
|
|
test('degradesToNullWhenReadPathIsWindowsDriveAbsolute', (t) => {
|
|
const dir = seedSinglePhaseProject(t, 'gsd-unsafe-windrive-');
|
|
const manifestPath = path.join(dir, 'unsafe-windrive-section-manifest.json');
|
|
fs.writeFileSync(manifestPath, JSON.stringify({
|
|
workflows: { 'execute-phase': [{ id: 'x', when: 'always', read: 'C:\\Windows\\System32\\config' }] },
|
|
}));
|
|
const result = runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: manifestPath });
|
|
assert.equal(result.status, 0, `expected exit 0 despite a Windows drive-absolute read path, got ${result.status} (stderr: ${result.stderr})`);
|
|
assertNoStackTrace(result.stderr, 'unsafe-read-windrive');
|
|
const body = JSON.parse(result.stdout);
|
|
assert.equal(body.section_manifest, null, 'a Windows drive-absolute `read` path must degrade the whole load to null');
|
|
});
|
|
});
|
|
|
|
// ── C1/C12/C16/C17 (#2992): shape guarantees on the field itself ────────
|
|
|
|
describe('init execute-phase: section_manifest field-shape guarantees (#2992)', () => {
|
|
test('emptySectionsArrayIsComputedNotDegraded (row C12 — present + empty is not null)', (t) => {
|
|
const dir = seedSinglePhaseProject(t, 'gsd-c12-');
|
|
const emptyWorkflowPath = path.join(dir, 'empty-workflow-section-manifest.json');
|
|
fs.writeFileSync(emptyWorkflowPath, JSON.stringify({ workflows: { 'execute-phase': [] } }));
|
|
const result = runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: emptyWorkflowPath });
|
|
assert.equal(result.status, 0, `expected exit 0, got ${result.status} (stderr: ${result.stderr})`);
|
|
const body = JSON.parse(result.stdout);
|
|
assert.deepStrictEqual(
|
|
body.section_manifest,
|
|
{ workflow: 'execute-phase', included: [], excluded: [], read: [] },
|
|
'a workflow key present with sections:[] must compute to an empty-but-present selection, never null',
|
|
);
|
|
});
|
|
|
|
test('workflowAbsentFromArtifactDegradesToNullNotEmptySections (row C16 — absence is not empty)', (t) => {
|
|
const dir = seedSinglePhaseProject(t, 'gsd-c16-');
|
|
const otherWorkflowPath = path.join(dir, 'other-workflow-section-manifest.json');
|
|
fs.writeFileSync(otherWorkflowPath, JSON.stringify({ workflows: { 'plan-phase': [{ id: 'x', when: 'always', read: 'x.md' }] } }));
|
|
const result = runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: otherWorkflowPath });
|
|
assert.equal(result.status, 0);
|
|
const body = JSON.parse(result.stdout);
|
|
assert.equal(body.section_manifest, null, 'execute-phase has no key in this artifact — must degrade to null, never {included:[]}');
|
|
});
|
|
|
|
test('executePhaseSectionManifestFieldIsByteIdenticalToThePreChangeShape (row C1 — Hyrum gate)', (t) => {
|
|
// Locks the EXACT shape execute-phase's real, shipped manifest produces
|
|
// for a plain (no --wave, no gap-closure, no prior phases) invocation —
|
|
// #2992 widened the vocabulary and generalized the artifact to
|
|
// per-workflow keying, but execute-phase's own 3 real sections (all
|
|
// pre-existing atoms) must select IDENTICALLY to before this change.
|
|
const dir = seedSinglePhaseProject(t, 'gsd-c1-');
|
|
const body = parseOkJson(runExecutePhase(['1'], dir), 'c1-baseline');
|
|
assert.deepStrictEqual(body.section_manifest, {
|
|
workflow: 'execute-phase',
|
|
included: [],
|
|
excluded: ['partial-wave', 'gap-closure-artifacts', 'regression-gate'],
|
|
read: [],
|
|
});
|
|
});
|
|
|
|
test('restOfTheInitBundleIsUnaffectedByEverySectionManifestDegradedCondition (row C17 — Hyrum gate)', (t) => {
|
|
// The `section_manifest` field is additive; every OTHER field of the
|
|
// init-bundle (22 direct dependents per the design doc's blast-radius
|
|
// table) must be byte-identical regardless of whether the manifest
|
|
// artifact resolves, is missing, or is malformed.
|
|
const dir = seedSinglePhaseProject(t, 'gsd-c17-');
|
|
|
|
const real = parseOkJson(runExecutePhase(['1'], dir), 'c17-real');
|
|
const missingPath = path.join(dir, 'does-not-exist-c17.json');
|
|
const missing = parseOkJson(runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: missingPath }), 'c17-missing');
|
|
const badPath = path.join(dir, 'bad-c17.json');
|
|
fs.writeFileSync(badPath, '{ not valid json');
|
|
const malformed = parseOkJson(runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: badPath }), 'c17-malformed');
|
|
|
|
delete real.section_manifest;
|
|
delete missing.section_manifest;
|
|
delete malformed.section_manifest;
|
|
assert.deepStrictEqual(missing, real, 'every other init-bundle field must be unaffected by a missing manifest artifact');
|
|
assert.deepStrictEqual(malformed, real, 'every other init-bundle field must be unaffected by a malformed manifest artifact');
|
|
});
|
|
});
|
|
|
|
// ── D4 (#2992, prod-shape): an absent CLI flag is absent from `flags` ────
|
|
//
|
|
// #2992 review finding (fixed in this change, src/init-command-router.cts):
|
|
// `parseNamedArgs`'s booleanFlags ALWAYS populate the option key (`true`
|
|
// when the token was seen, `false` otherwise — never `undefined`).
|
|
// `buildSectionManifestField`'s flags-Set builder (src/init.cts) treats
|
|
// ANY non-`undefined` option value as present (matrix D2/D3, for VALUE
|
|
// flags whose absence is `null`). Passed through unmodified, a
|
|
// booleanFlag's own `false` ("--wave" never typed) would still have been
|
|
// added to `flags`, making `flag:--wave` permanently true regardless of
|
|
// the actual invocation — verified live pre-fix: `partial-wave` was
|
|
// INCLUDED with no `--wave` on the command line at all, silently
|
|
// defeating the gating feature this whole seam exists for. The router now
|
|
// folds a booleanFlag's own `false` into `undefined` before it reaches
|
|
// `buildSectionManifestField`, so this test is the regression lock for
|
|
// that fix — it duplicates the assertion `emitsSectionManifestWithoutWaveFlag`
|
|
// already makes, under an explicit D4 name for matrix traceability.
|
|
//
|
|
// D2 ("option present, value false" as a generically-present option) and
|
|
// D3 ("option present, string value") and D6 (a hostile literal option
|
|
// key) are NOT independently prod-shape-testable through the real CLI as
|
|
// currently wired: `execute-phase`/`plan-phase` are the only two handlers
|
|
// that feed router-derived options into `buildSectionManifestField`, their
|
|
// option-key lists are FIXED literals (`validate`/`tdd`/`wave`/
|
|
// `granularity`) never derived from user input (so a hostile key name can
|
|
// never reach it for real), none of the 14 shipped vocabulary atoms is
|
|
// backed by a value-taking CLI flag (so a real string-valued option can
|
|
// never reach a `flag:` atom yet), and this fix means a booleanFlag's own
|
|
// `false` never reaches the builder at all anymore. Synthesizing an
|
|
// options object to force these paths would violate the matrix's own
|
|
// "(prod-shape) rows must drive the options path" constraint, so they are
|
|
// left unaddressed here rather than faked — surfaced for the orchestrator.
|
|
describe('init execute-phase: undefined CLI flag is absent from flags (#2992 row D4)', () => {
|
|
test('waveOptionAbsentWithoutTheFlagNeverActivatesPartialWave', (t) => {
|
|
const dir = seedSinglePhaseProject(t, 'gsd-d4-');
|
|
const body = parseOkJson(runExecutePhase(['1'], dir), 'd4-no-wave');
|
|
assert.ok(!body.section_manifest.included.includes('partial-wave'), 'an unset --wave must never leak into flags as "present"');
|
|
assert.deepStrictEqual(body.section_manifest.excluded, ['partial-wave', 'gap-closure-artifacts', 'regression-gate']);
|
|
});
|
|
});
|
|
|
|
// ── D9/D10/D11 (#2992, prod-shape): state:* detector degradation ─────────
|
|
// Drives the REAL cmdInitExecutePhase -> buildSectionManifestField ->
|
|
// readConfigJsonBoolean/detectPhaseMvpMode seam through the real CLI with a
|
|
// fixture manifest naming the two config-backed atoms, never a hand-built
|
|
// InvocationFacts (matrix note: "(prod-shape)" rows must drive the options
|
|
// path). `seedSinglePhaseProject` writes no `.planning/config.json` and no
|
|
// `.planning/ROADMAP.md` at all, so D9 (absent config) and D11 (absent
|
|
// ROADMAP) are the fixture's natural, un-monkeypatched state.
|
|
|
|
describe('init execute-phase: state:* detector degradation (#2992 rows D9-D11)', () => {
|
|
function writeDetectorManifest(dir) {
|
|
const manifestPath = path.join(dir, 'detector-section-manifest.json');
|
|
fs.writeFileSync(manifestPath, JSON.stringify({
|
|
workflows: {
|
|
'execute-phase': [
|
|
{ id: 'worktrees-section', when: 'state:worktrees-enabled', read: 'x.md' },
|
|
{ id: 'mvp-section', when: 'state:phase-mvp-mode', read: 'y.md' },
|
|
],
|
|
},
|
|
}));
|
|
return manifestPath;
|
|
}
|
|
|
|
test('absentConfigAndAbsentRoadmapDegradeBothStateAtomsToFalse (rows D9/D11)', (t) => {
|
|
const dir = seedSinglePhaseProject(t, 'gsd-d9-');
|
|
assert.equal(fs.existsSync(path.join(dir, '.planning', 'config.json')), false, 'sanity: no config.json');
|
|
assert.equal(fs.existsSync(path.join(dir, '.planning', 'ROADMAP.md')), false, 'sanity: no ROADMAP.md');
|
|
const manifestPath = writeDetectorManifest(dir);
|
|
const body = parseOkJson(runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: manifestPath }), 'd9-d11');
|
|
assert.deepStrictEqual(body.section_manifest.excluded, ['worktrees-section', 'mvp-section']);
|
|
assert.deepStrictEqual(body.section_manifest.included, []);
|
|
});
|
|
|
|
test('nonBooleanConfigValueDegradesToFalse (row D10 — strict boolean, string "true" is not true)', (t) => {
|
|
const dir = seedSinglePhaseProject(t, 'gsd-d10-');
|
|
fs.writeFileSync(path.join(dir, '.planning', 'config.json'), JSON.stringify({ workflow: { use_worktrees: 'true' } }));
|
|
const manifestPath = writeDetectorManifest(dir);
|
|
const body = parseOkJson(runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: manifestPath }), 'd10');
|
|
assert.ok(
|
|
body.section_manifest.excluded.includes('worktrees-section'),
|
|
'a string "true" config value must never coerce to boolean true',
|
|
);
|
|
});
|
|
|
|
test('realBooleanTrueConfigValueIncludesTheSection (independence: the strict check still accepts a real boolean)', (t) => {
|
|
const dir = seedSinglePhaseProject(t, 'gsd-d10b-');
|
|
fs.writeFileSync(path.join(dir, '.planning', 'config.json'), JSON.stringify({ workflow: { use_worktrees: true } }));
|
|
const manifestPath = writeDetectorManifest(dir);
|
|
const body = parseOkJson(runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: manifestPath }), 'd10b');
|
|
assert.ok(body.section_manifest.included.includes('worktrees-section'));
|
|
});
|
|
});
|
|
|
|
// ── #2992 review finding: state:needs-codebase-map wiring (real CLI) ─────
|
|
//
|
|
// No test anywhere drove `state:needs-codebase-map` (src/section-manifest.cts)
|
|
// or its `cmdInitNewProject` override wiring (src/init.cts, `overrides.needsCodebaseMap`)
|
|
// through the real CLI. Drives `init new-project` with a `mkdtempSync`
|
|
// fixture manifest (via `GSD_SECTION_MANIFEST`) naming a single section
|
|
// gated on the atom, proving inclusion/exclusion tracks the SAME
|
|
// isBrownfield/hasCodebaseMap computation `needs_codebase_map` itself uses
|
|
// — never mutating the shipped gsd-core/workflows/section-manifest.json.
|
|
|
|
describe('init new-project: state:needs-codebase-map wiring (#2992 review finding)', () => {
|
|
function writeNeedsCodebaseMapManifest(dir) {
|
|
const manifestPath = path.join(dir, 'needs-map-section-manifest.json');
|
|
fs.writeFileSync(manifestPath, JSON.stringify({
|
|
workflows: {
|
|
'new-project': [
|
|
{ id: 'needs-map-section', when: 'state:needs-codebase-map', read: 'z.md' },
|
|
],
|
|
},
|
|
}));
|
|
return manifestPath;
|
|
}
|
|
|
|
test('brownfieldWithoutCodebaseMapIncludesTheGatedSection', () => {
|
|
const dir = createTempProject('gsd-needsmap-true-');
|
|
try {
|
|
fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"test"}');
|
|
const manifestPath = writeNeedsCodebaseMapManifest(dir);
|
|
const result = runGsdTools('init new-project', dir, { GSD_SECTION_MANIFEST: manifestPath });
|
|
assert.ok(result.success, `init new-project failed: ${result.error}`);
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.needs_codebase_map, true, 'sanity: fixture must be brownfield without a codebase map');
|
|
assert.deepStrictEqual(output.section_manifest.included, ['needs-map-section']);
|
|
assert.deepStrictEqual(output.section_manifest.excluded, []);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('greenfieldExcludesTheGatedSection', () => {
|
|
const dir = createTempProject('gsd-needsmap-false-');
|
|
try {
|
|
const manifestPath = writeNeedsCodebaseMapManifest(dir);
|
|
const result = runGsdTools('init new-project', dir, { GSD_SECTION_MANIFEST: manifestPath });
|
|
assert.ok(result.success, `init new-project failed: ${result.error}`);
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.needs_codebase_map, false, 'sanity: fixture must be greenfield');
|
|
assert.deepStrictEqual(output.section_manifest.included, []);
|
|
assert.deepStrictEqual(output.section_manifest.excluded, ['needs-map-section']);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('brownfieldWithExistingCodebaseMapExcludesTheGatedSection', () => {
|
|
const dir = createTempProject('gsd-needsmap-hascomap-');
|
|
try {
|
|
fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"test"}');
|
|
fs.mkdirSync(path.join(dir, '.planning', 'codebase'), { recursive: true });
|
|
for (const name of ['STACK', 'ARCHITECTURE', 'STRUCTURE', 'CONVENTIONS', 'TESTING', 'INTEGRATIONS', 'CONCERNS']) {
|
|
fs.writeFileSync(path.join(dir, '.planning', 'codebase', `${name}.md`), `# ${name}\n`);
|
|
}
|
|
const manifestPath = writeNeedsCodebaseMapManifest(dir);
|
|
const result = runGsdTools('init new-project', dir, { GSD_SECTION_MANIFEST: manifestPath });
|
|
assert.ok(result.success, `init new-project failed: ${result.error}`);
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.needs_codebase_map, false, 'sanity: fixture must already have a complete codebase map');
|
|
assert.deepStrictEqual(output.section_manifest.included, []);
|
|
assert.deepStrictEqual(output.section_manifest.excluded, ['needs-map-section']);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── E59: independence — other init subcommands unaffected ───────────────
|
|
//
|
|
// #2992 (epic #1671 Phase 6.1) generalized the manifest seam from
|
|
// execute-phase-only to per-workflow (`buildSectionManifestField` now wires
|
|
// into `cmdInitPlanPhase`/`cmdInitNewProject`/`cmdInitNewMilestone`/
|
|
// `cmdInitQuick`/`cmdInitProgress` too — src/init.cts:740/787/839/885/1810).
|
|
// #2993 (epic #1671 Phase 6.2) makes the OLD assertion here doubly stale:
|
|
// plan-phase's field is no longer `null` at all — the shipped
|
|
// gsd-core/workflows/section-manifest.json now keys `plan-phase` too, with
|
|
// 6 real sections (row C1). A no-flag invocation excludes every one of
|
|
// them (none of the 5 governing flags/state were supplied). `resume` has
|
|
// no dedicated `cmdInit*` manifest wiring at all (design's withheld-atom
|
|
// survey), so its field truly remains absent — that half of the guard is
|
|
// unchanged.
|
|
|
|
describe('init dispatch: other subcommands unaffected (#2932/#2992/#2993 row 59, CRITICAL radius guard)', () => {
|
|
test('planPhaseEmitsARealComputedManifestNotNull (#2993 — the shipped artifact now has a plan-phase key)', (t) => {
|
|
const dir = seedSinglePhaseProject(t, 'gsd-e59-');
|
|
|
|
const planPhase = parseOkJson(runSectionManifestCli(['init.plan-phase', '1'], dir), 'plan-phase');
|
|
assert.equal(planPhase.phase_found, true);
|
|
assert.ok('section_manifest' in planPhase, 'section_manifest field must be present for plan-phase');
|
|
assert.deepStrictEqual(planPhase.section_manifest, {
|
|
workflow: 'plan-phase',
|
|
included: [],
|
|
excluded: [
|
|
'reviews-prerequisite',
|
|
'prd-express-gate',
|
|
'adr-ingest-express-path',
|
|
'research-only-modifiers',
|
|
'research-only-early-exit',
|
|
'chunked-planning-mode',
|
|
],
|
|
read: [],
|
|
});
|
|
});
|
|
|
|
test('resumeNeverEmitsASectionManifestField', (t) => {
|
|
const dir = seedSinglePhaseProject(t, 'gsd-e59-resume-');
|
|
const resume = parseOkJson(runSectionManifestCli(['init.resume'], dir), 'resume');
|
|
assert.ok(!('section_manifest' in resume), 'section_manifest must never leak into resume — resume has no cmdInit* manifest wiring at all');
|
|
});
|
|
});
|
|
|
|
// ── C6 (#2993): plan-phase's section_manifest degrades to null under a
|
|
// missing/malformed manifest artifact, exactly like execute-phase's own
|
|
// E57/E58 rows — the "safe superset" contract (all 6 sections read) is
|
|
// documented per-section in plan-phase.md's own stub prose ("If
|
|
// `section_manifest` is `null` or `"<id>"` is in its `included` list:
|
|
// read ... Otherwise skip"), verified for each of the 6 ids below; this
|
|
// test proves the JS-side half — that plan-phase really does receive
|
|
// `null`, never a stale/partial selection, under a degraded artifact.
|
|
|
|
describe('init plan-phase: section_manifest degrades to null (#2993 row C6)', () => {
|
|
test('degradesToNullManifestWhenArtifactMissing', (t) => {
|
|
const dir = seedSinglePhaseProject(t, 'gsd-c6-missing-');
|
|
const missingPath = path.join(dir, 'does-not-exist-section-manifest.json');
|
|
const result = runSectionManifestCli(['init.plan-phase', '1'], dir, { GSD_SECTION_MANIFEST: missingPath });
|
|
assert.equal(result.status, 0, `expected exit 0 despite missing manifest artifact, got ${result.status} (stderr: ${result.stderr})`);
|
|
assertNoStackTrace(result.stderr, 'plan-phase-manifest-missing');
|
|
const body = JSON.parse(result.stdout);
|
|
assert.equal(body.section_manifest, null);
|
|
});
|
|
|
|
test('degradesToNullManifestWhenArtifactMalformed', (t) => {
|
|
const dir = seedSinglePhaseProject(t, 'gsd-c6-malformed-');
|
|
const badPath = path.join(dir, 'bad-section-manifest.json');
|
|
fs.writeFileSync(badPath, '{ this is not valid json');
|
|
const result = runSectionManifestCli(['init.plan-phase', '1'], dir, { GSD_SECTION_MANIFEST: badPath });
|
|
assert.equal(result.status, 0, `expected exit 0 despite malformed manifest artifact, got ${result.status} (stderr: ${result.stderr})`);
|
|
assertNoStackTrace(result.stderr, 'plan-phase-manifest-malformed');
|
|
const body = JSON.parse(result.stdout);
|
|
assert.equal(body.section_manifest, null);
|
|
});
|
|
|
|
test('everyPlanPhaseSectionsHostStubDocumentsTheNullSafeSuperset (doc-level half of row C6)', () => {
|
|
const planPhasePath = path.join(GSD_ROOT, 'workflows', 'plan-phase.md');
|
|
const content = fs.readFileSync(planPhasePath, 'utf-8');
|
|
const ids = [
|
|
'reviews-prerequisite',
|
|
'prd-express-gate',
|
|
'adr-ingest-express-path',
|
|
'research-only-modifiers',
|
|
'research-only-early-exit',
|
|
'chunked-planning-mode',
|
|
];
|
|
for (const id of ids) {
|
|
const expectedGate = `If \`section_manifest\` is \`null\` or \`"${id}"\` is in its \`included\` list:`;
|
|
assert.ok(
|
|
content.includes(expectedGate),
|
|
`plan-phase.md must document the null-safe-superset gate for "${id}": expected to find "${expectedGate}"`,
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── #2993 (epic #1671 Phase 6.2) rows B1-B11: plan-phase facts assembly,
|
|
// driven through the REAL CLI (prod-shape — matrix section B header) ─────
|
|
|
|
describe('init plan-phase: new flag facts assembly (#2993 rows B1-B6)', () => {
|
|
function runPlanPhase(phaseArgs, cwd, env = {}) {
|
|
return runSectionManifestCli(['init.plan-phase', ...phaseArgs], cwd, env);
|
|
}
|
|
|
|
test('reviewsFlagPresentIncludesOnlyReviewsPrerequisite (row B1)', (t) => {
|
|
const dir = seedSinglePhaseProject(t, 'gsd-b1-present-');
|
|
const body = parseOkJson(runPlanPhase(['1', '--reviews'], dir), 'b1-present');
|
|
assert.deepStrictEqual(body.section_manifest.included, ['reviews-prerequisite']);
|
|
});
|
|
|
|
test('reviewsFlagAbsentExcludesReviewsPrerequisite (row B1)', (t) => {
|
|
const dir = seedSinglePhaseProject(t, 'gsd-b1-absent-');
|
|
const body = parseOkJson(runPlanPhase(['1'], dir), 'b1-absent');
|
|
assert.ok(!body.section_manifest.included.includes('reviews-prerequisite'));
|
|
assert.ok(body.section_manifest.excluded.includes('reviews-prerequisite'));
|
|
});
|
|
|
|
test('prdFlagWithValuePresentIncludesOnlyPrdExpressGate (row B2)', (t) => {
|
|
const dir = seedSinglePhaseProject(t, 'gsd-b2-present-');
|
|
const body = parseOkJson(runPlanPhase(['1', '--prd', 'some-prd.md'], dir), 'b2-present');
|
|
assert.deepStrictEqual(body.section_manifest.included, ['prd-express-gate']);
|
|
});
|
|
|
|
test('prdFlagAbsentExcludesPrdExpressGate (row B2 — value flag absence is null)', (t) => {
|
|
const dir = seedSinglePhaseProject(t, 'gsd-b2-absent-');
|
|
const body = parseOkJson(runPlanPhase(['1'], dir), 'b2-absent');
|
|
assert.ok(!body.section_manifest.included.includes('prd-express-gate'));
|
|
assert.ok(body.section_manifest.excluded.includes('prd-express-gate'));
|
|
});
|
|
|
|
test('ingestFlagWithValuePresentIncludesOnlyAdrIngestExpressPath (row B3)', (t) => {
|
|
const dir = seedSinglePhaseProject(t, 'gsd-b3-present-');
|
|
const body = parseOkJson(runPlanPhase(['1', '--ingest', 'some-adr.md'], dir), 'b3-present');
|
|
assert.deepStrictEqual(body.section_manifest.included, ['adr-ingest-express-path']);
|
|
});
|
|
|
|
test('ingestFlagAbsentExcludesAdrIngestExpressPath (row B3)', (t) => {
|
|
const dir = seedSinglePhaseProject(t, 'gsd-b3-absent-');
|
|
const body = parseOkJson(runPlanPhase(['1'], dir), 'b3-absent');
|
|
assert.ok(!body.section_manifest.included.includes('adr-ingest-express-path'));
|
|
assert.ok(body.section_manifest.excluded.includes('adr-ingest-express-path'));
|
|
});
|
|
|
|
test('researchPhaseFlagWithValuePresentIncludesBothResearchOnlySections (row B4)', (t) => {
|
|
const dir = seedSinglePhaseProject(t, 'gsd-b4-present-');
|
|
const body = parseOkJson(runPlanPhase(['1', '--research-phase', '3'], dir), 'b4-present');
|
|
assert.deepStrictEqual(body.section_manifest.included, ['research-only-modifiers', 'research-only-early-exit']);
|
|
});
|
|
|
|
test('researchPhaseFlagAbsentExcludesBothResearchOnlySections (row B4)', (t) => {
|
|
const dir = seedSinglePhaseProject(t, 'gsd-b4-absent-');
|
|
const body = parseOkJson(runPlanPhase(['1'], dir), 'b4-absent');
|
|
assert.ok(!body.section_manifest.included.includes('research-only-modifiers'));
|
|
assert.ok(!body.section_manifest.included.includes('research-only-early-exit'));
|
|
});
|
|
|
|
test('emptyPrdValueIsFalsyAndTreatedAsAbsent (row B5 — no spurious inclusion)', (t) => {
|
|
// `--prd` immediately followed by another flag token (no value token
|
|
// present at all) resolves to `null` in parseNamedArgs — the "empty
|
|
// value" shape for a value flag. Combined with a second, independently
|
|
// gated flag to prove ONLY the second flag's section activates.
|
|
const dir = seedSinglePhaseProject(t, 'gsd-b5-');
|
|
const body = parseOkJson(runPlanPhase(['1', '--prd', '--reviews'], dir), 'b5');
|
|
assert.ok(!body.section_manifest.included.includes('prd-express-gate'), '--prd with no value must never spuriously include prd-express-gate');
|
|
assert.deepStrictEqual(body.section_manifest.included, ['reviews-prerequisite']);
|
|
});
|
|
|
|
test('chunkedFlagPresentIncludesChunkedPlanningMode (row B6)', (t) => {
|
|
const dir = seedSinglePhaseProject(t, 'gsd-b6-');
|
|
const body = parseOkJson(runPlanPhase(['1', '--chunked'], dir), 'b6');
|
|
assert.deepStrictEqual(body.section_manifest.included, ['chunked-planning-mode']);
|
|
});
|
|
});
|
|
|
|
describe('init plan-phase: state:chunked-mode disjunction — flag OR config (#2993 rows B7-B11)', () => {
|
|
function runPlanPhase(phaseArgs, cwd, env = {}) {
|
|
return runSectionManifestCli(['init.plan-phase', ...phaseArgs], cwd, env);
|
|
}
|
|
|
|
function writeConfig(dir, workflowConfig) {
|
|
fs.writeFileSync(path.join(dir, '.planning', 'config.json'), JSON.stringify({ workflow: workflowConfig }));
|
|
}
|
|
|
|
test('chunkedFlagAbsentConfigTrueIncludesChunkedPlanningMode (row B7 — config arm of the disjunction)', (t) => {
|
|
const dir = seedSinglePhaseProject(t, 'gsd-b7-');
|
|
writeConfig(dir, { plan_chunked: true });
|
|
const body = parseOkJson(runPlanPhase(['1'], dir), 'b7');
|
|
assert.deepStrictEqual(body.section_manifest.included, ['chunked-planning-mode']);
|
|
});
|
|
|
|
test('chunkedFlagAbsentConfigFalseExcludesChunkedPlanningMode (row B8)', (t) => {
|
|
const dir = seedSinglePhaseProject(t, 'gsd-b8-false-');
|
|
writeConfig(dir, { plan_chunked: false });
|
|
const body = parseOkJson(runPlanPhase(['1'], dir), 'b8-false');
|
|
assert.ok(!body.section_manifest.included.includes('chunked-planning-mode'));
|
|
});
|
|
|
|
test('chunkedFlagAbsentConfigAbsentExcludesChunkedPlanningMode (row B8)', (t) => {
|
|
const dir = seedSinglePhaseProject(t, 'gsd-b8-absent-');
|
|
writeConfig(dir, {});
|
|
const body = parseOkJson(runPlanPhase(['1'], dir), 'b8-absent');
|
|
assert.ok(!body.section_manifest.included.includes('chunked-planning-mode'));
|
|
});
|
|
|
|
test('chunkedFlagAbsentConfigFileMissingExcludesChunkedPlanningMode (row B8)', (t) => {
|
|
const dir = seedSinglePhaseProject(t, 'gsd-b8-nofile-');
|
|
assert.equal(fs.existsSync(path.join(dir, '.planning', 'config.json')), false, 'sanity: no config.json');
|
|
const body = parseOkJson(runPlanPhase(['1'], dir), 'b8-nofile');
|
|
assert.ok(!body.section_manifest.included.includes('chunked-planning-mode'));
|
|
});
|
|
|
|
test('configStringTrueDegradesToFalse (row B9 — strict === true, never coerced)', (t) => {
|
|
const dir = seedSinglePhaseProject(t, 'gsd-b9-');
|
|
writeConfig(dir, { plan_chunked: 'true' });
|
|
const body = parseOkJson(runPlanPhase(['1'], dir), 'b9');
|
|
assert.ok(
|
|
!body.section_manifest.included.includes('chunked-planning-mode'),
|
|
'a string "true" config value must never coerce to boolean true',
|
|
);
|
|
});
|
|
|
|
test('configReadThrowsDegradesToFalseBoundedNeverPropagates (row B10)', (t) => {
|
|
// `readConfigJsonBoolean` (src/init.cts) is private and reads
|
|
// `.planning/config.json` via `fs.readFileSync`, so its `catch` clause
|
|
// cannot be exercised via an in-process fs monkeypatch across the
|
|
// spawned-CLI process boundary this suite otherwise drives (the whole
|
|
// matrix section is prod-shape: "drive the real CLI"). A directory at
|
|
// the config path forces a REAL, deterministic, cross-platform fs
|
|
// fault (EISDIR-class on every OS `fs.readFileSync` targets) through
|
|
// the real CLI — never a chmod/permission trick — landing on the same
|
|
// bounded, non-throwing degrade path a monkeypatched throw would.
|
|
const dir = seedSinglePhaseProject(t, 'gsd-b10-');
|
|
// sanity: seedSinglePhaseProject never writes a config.json, so this is
|
|
// the fixture's own natural state, not a removal.
|
|
assert.equal(fs.existsSync(path.join(dir, '.planning', 'config.json')), false);
|
|
fs.mkdirSync(path.join(dir, '.planning', 'config.json'));
|
|
const result = runPlanPhase(['1'], dir);
|
|
assert.equal(result.status, 0, `expected exit 0 despite an unreadable config.json, got ${result.status} (stderr: ${result.stderr})`);
|
|
assertNoStackTrace(result.stderr, 'config-read-throws');
|
|
const body = JSON.parse(result.stdout);
|
|
assert.ok(!body.section_manifest.included.includes('chunked-planning-mode'), 'a config read fault must degrade chunkedMode to false, never throw or propagate');
|
|
});
|
|
|
|
test('nonObjectConfigJsonDegradesToFalse (row B11)', (t) => {
|
|
const dir = seedSinglePhaseProject(t, 'gsd-b11-');
|
|
for (const hostileJson of ['0', '"s"', '[]', 'null', 'true']) {
|
|
fs.writeFileSync(path.join(dir, '.planning', 'config.json'), hostileJson);
|
|
const result = runPlanPhase(['1'], dir);
|
|
assert.equal(result.status, 0, `hostile config JSON ${hostileJson}: expected exit 0 (stderr: ${result.stderr})`);
|
|
assertNoStackTrace(result.stderr, `hostile-config-json:${hostileJson}`);
|
|
const body = JSON.parse(result.stdout);
|
|
assert.ok(
|
|
!body.section_manifest.included.includes('chunked-planning-mode'),
|
|
`hostile config JSON ${hostileJson} must degrade chunkedMode to false`,
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── Row 62: stub <execution_context> @-refs still resolve (ADR-0002) ────
|
|
|
|
describe('commands/gsd/execute-phase.md: <execution_context> @-refs resolve (#2932 row 62)', () => {
|
|
test('stubExecutionContextRefStillResolves', () => {
|
|
const stubPath = path.join(COMMANDS_DIR, 'execute-phase.md');
|
|
const content = fs.readFileSync(stubPath, 'utf-8');
|
|
const refs = executionContextRefs(content);
|
|
assert.ok(refs.length > 0, 'execute-phase.md stub must declare at least one execution_context @-ref');
|
|
|
|
const workflowRef = refs.find((r) => r.normalized === 'workflows/execute-phase.md');
|
|
assert.ok(workflowRef, 'execute-phase.md stub must @-reference workflows/execute-phase.md');
|
|
|
|
for (const ref of refs) {
|
|
assert.ok(
|
|
fs.existsSync(path.join(GSD_ROOT, ref.normalized)),
|
|
`execution_context @-ref "${ref.normalized}" must resolve to a file that exists on disk`,
|
|
);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// #3171 (Claim 3): the phase-start flow must not land a directory slug in
|
|
// STATE.md's `current_phase_name`. When a phase directory already exists on
|
|
// disk, `init execute-phase`'s disk-lookup path derived `phase_name` from the
|
|
// directory-name remainder — itself an already-slugified value (`phase.add`
|
|
// writes `${num}-${slug}` dirs) — so `phase_name` and `phase_slug` came out
|
|
// byte-identical, and the execute-phase workflow forwarded that slug into
|
|
// `state begin-phase --name`. The milestone-name half of #3171 was subsumed
|
|
// by #3216 / PR #3226; these tests cover the remaining current_phase_name half.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('#3171: init execute-phase emits the display name, not the directory slug', () => {
|
|
const DISPLAY_NAME = 'Loop-Termination and Baseline Correctness';
|
|
const PHASE_SLUG_DIR = '35-loop-termination-and-baseline-correctness';
|
|
const ROADMAP_3171 = [
|
|
'# Roadmap',
|
|
'',
|
|
`### Phase 35: ${DISPLAY_NAME}`,
|
|
'**Goal:** Fix loop termination',
|
|
'**Plans:** 1 plans',
|
|
'',
|
|
].join('\n');
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.realpathSync(createFixture());
|
|
seedPhase(tmpDir, PHASE_SLUG_DIR, { '35-01-PLAN.md': '# Plan' });
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), ROADMAP_3171);
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('phase_name is the ROADMAP display name when the phase directory exists', () => {
|
|
const result = runGsdTools('init execute-phase 35 --raw', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_found, true, 'phase must be found on disk');
|
|
assert.ok(
|
|
typeof output.phase_dir === 'string' && output.phase_dir.includes(PHASE_SLUG_DIR),
|
|
`phase_dir must point at the on-disk directory; got ${JSON.stringify(output.phase_dir)}`,
|
|
);
|
|
assert.strictEqual(
|
|
output.phase_name,
|
|
DISPLAY_NAME,
|
|
`phase_name must be the ROADMAP display name, not the directory slug; got ${JSON.stringify(output.phase_name)}`,
|
|
);
|
|
assert.strictEqual(output.phase_slug, 'loop-termination-and-baseline-correctness');
|
|
assert.notStrictEqual(output.phase_name, output.phase_slug,
|
|
'phase_name must differ from phase_slug — a byte-identical pair is the #3171 defect signature');
|
|
});
|
|
|
|
test('the phase-start flow does not land a slug in current_phase_name', () => {
|
|
// 1. init execute-phase → the value the execute-phase workflow forwards to begin-phase.
|
|
const initResult = runGsdTools('init execute-phase 35 --raw', tmpDir);
|
|
assert.ok(initResult.success, `init execute-phase failed: ${initResult.error}`);
|
|
const initOutput = JSON.parse(initResult.output);
|
|
assert.strictEqual(initOutput.phase_name, DISPLAY_NAME);
|
|
|
|
// 2. Seed a STATE.md the transition module can rewrite (frontmatter + body).
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'STATE.md'),
|
|
[
|
|
'---',
|
|
'gsd_state_version: 1.0',
|
|
'current_phase: 34',
|
|
'current_phase_name: Prior Phase',
|
|
'status: planning',
|
|
'---',
|
|
'',
|
|
'# Project State',
|
|
'',
|
|
'## Current Position',
|
|
'',
|
|
'Phase: 34 — Prior Phase',
|
|
'Plan: Not started',
|
|
'Status: Ready to execute',
|
|
'',
|
|
].join('\n'),
|
|
);
|
|
|
|
// 3. The orchestrator wiring: feed init's phase_name into begin-phase --name.
|
|
const beginResult = runGsdTools(
|
|
['state', 'begin-phase', '--phase', '35', '--name', initOutput.phase_name, '--plans', '1'],
|
|
tmpDir,
|
|
);
|
|
assert.ok(beginResult.success, `state begin-phase failed: ${beginResult.error}`);
|
|
|
|
// 4. current_phase_name in STATE.md must be the display name, never the slug.
|
|
const stateContent = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
|
const fm = parseFrontmatter(stateContent);
|
|
assert.strictEqual(
|
|
fm.current_phase_name,
|
|
DISPLAY_NAME,
|
|
`current_phase_name must hold the display name, not a directory slug; got ${JSON.stringify(fm.current_phase_name)}`,
|
|
);
|
|
assert.ok(
|
|
!/^[a-z0-9]+(-[a-z0-9]+)+$/.test(String(fm.current_phase_name)),
|
|
`current_phase_name must not be slug-shaped; got ${JSON.stringify(fm.current_phase_name)}`,
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── #3581: init.progress frontier prefers roadmap order over stray artifacts ──
|
|
describe('#3581: init.progress next_phase prefers the roadmap frontier', () => {
|
|
function writeProgressFixture(t, { strayNine, completeAll }) {
|
|
fs.writeFileSync(path.join(tmpDirOf(t), '.planning', 'ROADMAP.md'),
|
|
['# Roadmap', '', '## Milestone v1.1.0', '', '### Phase 8: Payments', '**Goal:** g', '', '### Phase 9: Compatibility', '**Goal:** g', ''].join('\n'));
|
|
fs.writeFileSync(path.join(tmpDirOf(t), '.planning', 'STATE.md'), [
|
|
'---', 'gsd_state_version: 1.0', 'milestone: v1.1.0', 'milestone_name: Active',
|
|
'status: executing', 'current_phase: 8', 'progress:', ' total_phases: 9',
|
|
' completed_phases: 7', ' percent: 78', '---', '', '# Project State', '',
|
|
'## Current Position', '', 'Phase: 8', 'Status: Executing',
|
|
].join('\n'));
|
|
if (strayNine) {
|
|
const nine = path.join(tmpDirOf(t), '.planning', 'phases', '09-live-compatibility-diagnostics');
|
|
fs.mkdirSync(nine, { recursive: true });
|
|
fs.writeFileSync(path.join(nine, 'UAT.md'), '# UAT evidence\n');
|
|
}
|
|
if (completeAll) {
|
|
// both phases complete on disk (plans, summaries, PASSING verification —
|
|
// the #3168 disk-strict bar) + roadmap checkboxes
|
|
for (const dir of ['08-payments', '09-compatibility']) {
|
|
const d = path.join(tmpDirOf(t), '.planning', 'phases', dir);
|
|
fs.mkdirSync(d, { recursive: true });
|
|
fs.writeFileSync(path.join(d, 'PLAN.md'), '# p\n');
|
|
fs.writeFileSync(path.join(d, 'SUMMARY.md'), '# s\n');
|
|
fs.writeFileSync(path.join(d, `${dir.split('-')[0]}-VERIFICATION.md`), '---\nstatus: passed\n---\n\n# V\n');
|
|
}
|
|
}
|
|
}
|
|
// local alias so the helper reads the same as the suite's own fixtures
|
|
function tmpDirOf(t) { return t.tmpDir3581 ?? (t.tmpDir3581 = createTempProject('gsd-3581-')); }
|
|
|
|
test('#3581: init.progress prefers the roadmap frontier over a stray out-of-order artifact', (t) => {
|
|
writeProgressFixture(t, { strayNine: true });
|
|
t.after(() => cleanup(tmpDirOf(t)));
|
|
const result = runGsdTools(['init', 'progress', '--raw'], tmpDirOf(t));
|
|
assert.ok(result.success, `init progress failed: ${result.error}`);
|
|
const out = JSON.parse(result.output);
|
|
assert.ok(out.next_phase, `next_phase present; got keys ${Object.keys(out)}`);
|
|
assert.equal(String(out.next_phase.number).replace(/^0+/, ''), '8',
|
|
`the roadmap's Phase 8 (pending, unscaffolded) must be the frontier — not the stray 09 artifact dir; got ${out.next_phase.number}`);
|
|
const eight = (out.phases || []).find((p) => String(p.number).replace(/^0+/, '') === '8');
|
|
assert.ok(eight, 'Phase 8 present in the phases array (roadmap-derived)');
|
|
assert.equal(eight.directory, null, 'Phase 8 has no directory (corroborating the stray-only-disk shape)');
|
|
});
|
|
|
|
test('#3581 (control): a pending roadmap-only phase outranks a later pending directory', (t) => {
|
|
writeProgressFixture(t, { strayNine: false });
|
|
// pure ordering property, no stray artifacts: roadmap-only pending 8 vs a
|
|
// pending 9 DIRECTORY (empty). The pinned mixed-statuses contract (an
|
|
// in-progress phase is currentPhase's lane, not nextPhase's) is untouched.
|
|
const nine = path.join(tmpDirOf(t), '.planning', 'phases', '09-compatibility');
|
|
fs.mkdirSync(nine, { recursive: true });
|
|
t.after(() => cleanup(tmpDirOf(t)));
|
|
const result = runGsdTools(['init', 'progress', '--raw'], tmpDirOf(t));
|
|
assert.ok(result.success, `init progress failed: ${result.error}`);
|
|
const out = JSON.parse(result.output);
|
|
assert.equal(String(out.next_phase.number).replace(/^0+/, ''), '8',
|
|
'the unscaffolded roadmap Phase 8 is the frontier even against a legitimately-pending 9 directory');
|
|
});
|
|
|
|
test('#3581 (boundary): completed milestone yields no frontier', (t) => {
|
|
writeProgressFixture(t, { strayNine: false, completeAll: true });
|
|
fs.writeFileSync(path.join(tmpDirOf(t), '.planning', 'ROADMAP.md'),
|
|
['# Roadmap', '', '## Milestone v1.1.0', '', '- [x] **Phase 8: Payments**', '- [x] **Phase 9: Compatibility**', ''].join('\n'));
|
|
t.after(() => cleanup(tmpDirOf(t)));
|
|
const result = runGsdTools(['init', 'progress', '--raw'], tmpDirOf(t));
|
|
assert.ok(result.success, `init progress failed: ${result.error}`);
|
|
const out = JSON.parse(result.output);
|
|
assert.equal(out.next_phase, null, 'all-complete milestone: no frontier (completion flow owns the answer)');
|
|
});
|
|
});
|