refactor(tests): consolidate Init Command Module — 7 files → 5 (#3756)
* fix(3687): update insert-phase docs and roadmapper to use --insert flag Updates stale references in insert-phase.md workflow and gsd-roadmapper.md agent to use the consolidated /gsd:phase --insert command syntax instead of the retired /gsd-insert-phase and /gsd:phase insert forms. Closes #3687 * refactor(tests): consolidate Init Command Module — 7 files → 5 Closes #3755 Merges `tests/init-manager-deps.test.cjs` (#2267 regression) into `tests/init-manager.test.cjs` (718 LOC), and `sdk/src/query/init-progress-precedence.test.ts` (#2674 regression) into `sdk/src/query/init-complex.test.ts` (788 LOC). The 800 LOC ceiling prevents further consolidation: - `tests/init.test.cjs` is pre-existing at 1630 LOC - `sdk/src/query/init.test.ts` is at 791 LOC - `sdk/src/query/init-workstream-milestone-op.test.ts` is a distinct seam testing initMilestoneOp, roadmapAnalyze, and resolveQueryRuntimeContext workstream resolution. Also adds Init Command Module Glossary entry to CONTEXT.md. Allowlist update deferred to rebase after #3738 merges (allowlist file does not exist on origin/main). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(init): initExecutePhase preserves same-milestone archived phase dir (#3469) When `phases clear` archives current-milestone phases into `.planning/milestones/<version>-phases/` but the workflow is still on that same milestone, `shouldDropArchivedPhaseMatch` was unconditionally dropping the archived dir match. This caused `phase_dir: null` when the phase was still executing in the current milestone. Fix: detect when `phaseInfo.archived === currentMilestone` (read from STATE.md) and skip the drop. The #2391 regression guard is safe because that scenario involves archived.version != current milestone. Also corrects two tests in `initRemoveWorkspace` to expect thrown GSDError instead of `{ data: { error } }` — the production code was intentionally changed to throw for CLI non-zero exit propagation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: arya rizky <aryarizkyardhipratama@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
7
.changeset/3755-consolidate-init-tests.md
Normal file
7
.changeset/3755-consolidate-init-tests.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: Changed
|
||||
pr: 3755
|
||||
---
|
||||
Internal test refactor: consolidates the Init Command Module test cluster from 7 files to 5.
|
||||
|
||||
<!-- docs-exempt: internal test refactor only — no user-facing surface changed -->
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -70,3 +70,11 @@ vendor/
|
||||
tmp/
|
||||
.worktrees
|
||||
.envrc
|
||||
|
||||
# MemPalace per-project files (issue #185)
|
||||
mempalace.yaml
|
||||
entities.json
|
||||
|
||||
# Local scratch + Claude-test artifacts
|
||||
.scratch/
|
||||
claude-test-command.md
|
||||
|
||||
40
CONTEXT.md
40
CONTEXT.md
@@ -55,6 +55,9 @@ Canonical command normalization and resolution Interface (`query-command-resolut
|
||||
### Command Topology Module
|
||||
Module owning command resolution, policy projection (`mutation`, `output_mode`), unknown-command diagnosis, and handler Adapter binding at one seam for query dispatch.
|
||||
|
||||
### Init Command Module
|
||||
SDK Module owning the `init.*` family of query handlers that compose atomic SDK queries into the flat JSON bundles consumed by init workflows (`/gsd-execute-phase`, `/gsd-plan-phase`, `/gsd-verify-work`, `/gsd-new-project`, `/gsd-manager`, `/gsd-progress`, `/gsd-resume`, etc.). Divided into two source files: `sdk/src/query/init.ts` (13 basic handlers plus `withProjectRoot` project-identity injection) and `sdk/src/query/init-complex.ts` (the 3 heavyweight handlers: `initNewProject`, `initProgress`, `initManager`). All handlers return `{ data: <flat JSON> }` aligned with the CJS `bin/lib/init.cjs` surface. Test seams: `sdk/src/query/init.test.ts` (basic handlers + withProjectRoot precedence), `sdk/src/query/init-complex.test.ts` (complex handlers + progress/manager precedence regression #2674 + workstream scoping regression #3196), `sdk/src/query/init-workstream-milestone-op.test.ts` (initMilestoneOp + roadmapAnalyze + resolveQueryRuntimeContext workstream fallback). CJS CLI surface tested in `tests/init.test.cjs` and `tests/init-manager.test.cjs` (includes cross-milestone dependency regression #2267).
|
||||
|
||||
### CJS Command Router Adapter Module
|
||||
Compatibility Adapter Module for `gsd-tools.cjs` command families. Uses generated command metadata plus small argument shapers to route to CJS handlers, rather than calling SDK Command Topology directly. Preserves CJS compatibility startup while reducing hand-written router drift. Per-family migration to call the **Sync Runtime Bridge Module**'s `executeForCjs` in-process — eliminating the remaining parallel CJS handler implementations — is the active work of #3524 Phase 5; the primitive itself ships in #3555, with each canonical command family (`state.*`, `verify.*`, `init.*`, `phase.*`, `phases.*`, `validate.*`, `roadmap.*`, `frontmatter.*`, `config.*`) routing through `executeForCjs` in its own follow-up enhancement.
|
||||
|
||||
@@ -514,6 +517,43 @@ Migration plan: Phase 1 (#3465) seam additions complete; Phase 2 (#3466) targets
|
||||
`SESSION.2026-05-14=[#3095/PR #3490 EXEC.CLASSIFY.* introduced (Anthropic/Copilot/Codex/Gemini cross-runtime rate-limit sentinel coverage); #3489/PR #3499 DEFECT.STATE-TRAMPLE.idempotency-oracle (STATE.md current_phase field is oracle for state.complete-phase); #3488/PR #3501 DAG resolver same-phase short-form depends_on (shortFormToId index added to sdk/src/query/phase.ts); #3491/PR #3502 DEFECT.NESTED-GIT-INIT (gitWorktreeInfoInternal helper); #3493/PR #3500 extractCurrentMilestone generic Phase Details continuation past planned-milestone siblings; #3503/PR #3504 DEFECT.PATH-SUBSTRING-CHECK (trailing-slash anchor for homedir checks); #3346/PR #3505 codex AoT TOML leaf-key via extractFlatHookEventName; #3506/PR #3507 label-scoped stale-bot sub-job pattern; multi-PR triage operational lessons folded into PROC.TRIAGE.*; #3508 DEFECT.AGENT-ISOLATION-SILENT-FAIL; gsd-test image-missing auto-build (locally-built image via embedded heredoc Dockerfile); refined PRED.k322 threshold to 3 PRs/<10min]`
|
||||
`SESSION.2026-05-15=[#3537/PR #3538 DEFECT.PHASE-REGEX-FANOUT — phaseMarkdownRegexSource promoted to core.cjs and wired to 7 sites; parity-style regression test established as DEFECT.GENERATIVE-FIX exemplar; trek-e/gsd-test-runner#1 filed for DEFECT.GSD-TEST-MIRROR-POISONED — chown-back-before-exec legacy gap (poisoned holodeck mirror unstuck via authorized docker chown to remote 1000:1000); RULESET.PR-FLOW.* codified from project CLAUDE.md load-bearing rule; first dispatch under run-tests-before-create held cleanly (PR #3520 worker stopped on Docker exit 12 infra failure, orchestrator opened PR after unblock); CONTEXT.md refactored from 882 lines of mixed prose+predicates into ~500 lines of pure-predicate format with chronological session log]`
|
||||
`SESSION.2026-05-15.parallel-fix-dispatch=[#3542/PR #3546 prohibit git stash family in executor agents (shared refs/stash across worktrees); #3541/PR #3547 non-TTY resolution for installer prompt-user actions (default remove for SDK build artifacts, keep for skills/gsd-*/SKILL.md); #3545 filed for gsd-test-summary concurrent /tmp output collision; new predicates DEFECT.HOOK-OVER-ENFORCEMENT.read-tool-tracking, DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION, DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL, DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT, PROC.PARALLEL-FIX-DISPATCH; agent-trust-but-verify caught /gsd-update retired-syntax comment slip in #3541 implementation before PR open]`
|
||||
`SESSION.2026-05-16=[multi-PR triage wave (#3577/3581/3640/3641/3642/3648/3649/3637/3639). Established global PreToolUse hook ~/.claude/hooks/test-memory-guard.sh denying new node/test spawns when sum(RSS of node|vitest|jest|...) >= 4 GiB on the 24 GB Mac OR when a same-runner process is already in argv[0] — hard deny via hookSpecificOutput.permissionDecision=deny. PR #3577 fix: revert config-ensure-section dispatch to CJS cmdConfigEnsureSection (SDK author wrote single-section semantics under a name whose legacy callers expect full-default config init); plus 3 SDK parity carve-outs (configNewProject defaults align with sdk/shared/config-defaults.manifest.json, return relative .planning/config.json path, drop quotes from Unknown config key, lead malformed-JSON error with "Failed to read config.json:"). PR #3649 fix: chunk node --test spawn at 28K argv ceiling (Windows CreateProcess lpCommandLine cap 32,767 was instantly aborting unchunked spawn of 546 paths). Chunking fix surfaced 14 pre-existing Windows-only test bugs (4010 pass / 14 fail; vs 0/0 before — entire suite was un-runnable on Windows). PRs #3639 + #3637 confirmed unable to stand alone (legitimately depend on Phase 6 scaffolding only present on feat/3575-enforcement-hardening) — user decision: cherry-pick into #3577 and close. Five other PRs each had ≤1 unresolved CR thread of the changeset-pr-number / null-vs-throw / implicit-Claude-runtime / docs-stale-guidance / hardcoded-tests-path family — all quick wins. New predicates: DEFECT.SDK-PORT-NAME-COLLISION, DEFECT.WINDOWS-ARGV-OVERFLOW, DEFECT.STACKED-PR-CANNOT-STAND-ALONE, DEFECT.CANARY-VERSION-LEAK, DEFECT.GSD-TEST-HOST-MID-RUN-DEATH, RULESET.HARNESS.test-memory-guard, RULESET.PR-FLOW.docker-before-push, RULESET.PR-FLOW.templates-mandatory]`
|
||||
|
||||
`DEFECT.SDK-PORT-NAME-COLLISION.symptom=SDK author writes new handler with same canonical name as legacy CJS command but different positional-arg shape; Phase-N router migration silently rebinds CLI dispatch to the new SDK function and every legacy no-arg / wrong-arg caller errors out at the handler's own validation throw`
|
||||
`DEFECT.SDK-PORT-NAME-COLLISION.examples=#3577 config-ensure-section (legacy CJS = no-arg full-default init via ensureConfigFile→buildNewProjectConfig; new SDK configEnsureSection = single-section ensure requiring args[0]; all CLI callers pass no args; SDK throws "Usage: config-ensure-section <section>")`
|
||||
`DEFECT.SDK-PORT-NAME-COLLISION.detect=grep new-handler name in sdk/src/query/command-static-catalog-foundation.ts catalog → trace every CLI/test caller of the canonical name → if any caller's argv shape differs from the new SDK handler's args[0] expectation, the migration broke the legacy contract`
|
||||
`DEFECT.SDK-PORT-NAME-COLLISION.fix-forward=either (a) bind the catalog entry to a SDK handler whose body mirrors legacy semantics (e.g. configNewProject when no args), or (b) keep the dispatch case calling the CJS handler directly and remove the SDK-bridge attempt (precedent: 7d5dfa9d codex runtime carve-out). Whichever path, add a behavioral test that round-trips the legacy invocation shape to lock the contract`
|
||||
`DEFECT.SDK-PORT-NAME-COLLISION.generative-tie=instance of DEFECT.GENERATIVE-PRIORITY — parity assertion at the test layer between CJS handler shape and SDK handler shape would have failed at PR open`
|
||||
|
||||
`DEFECT.WINDOWS-ARGV-OVERFLOW.symptom=execFileSync(node, ['--test', ...N paths]) succeeds on Linux/macOS, instantly exits with code 1 and no test output on Windows when N×avg(path_len) exceeds 32,767 chars (CreateProcess lpCommandLine cap)`
|
||||
`DEFECT.WINDOWS-ARGV-OVERFLOW.examples=#3649 scripts/run-tests.cjs spawning 546 paths (~85 chars each ≈ 46 KB); Linux ARG_MAX 2 MB allows it, Windows aborts in ~70 ms with zero test output making the failure look like the runner itself crashed`
|
||||
`DEFECT.WINDOWS-ARGV-OVERFLOW.detect=Windows CI job at "Run unit tests" exits with code 1 within seconds of starting, no node:test output between "run-tests: suite=… files=N: …" line and "Process completed with exit code 1"; same job on Linux/macOS runs full duration`
|
||||
`DEFECT.WINDOWS-ARGV-OVERFLOW.fix-forward=chunk argv into batches whose total length stays under 28,000 chars (headroom under the 32,767 ceiling); run each chunk sequentially; aggregate exit codes (first non-zero wins). Expose RUN_TESTS_MAX_CMDLINE_CHARS env override so cross-platform regression tests can force chunking with short tmp paths`
|
||||
`DEFECT.WINDOWS-ARGV-OVERFLOW.test-anchor=tests/run-tests-harness.test.cjs "Windows argv-overflow chunking (issue #3597)" — 30 long-named fixture files + RUN_TESTS_MAX_CMDLINE_CHARS=2000 → asserts run-tests: chunk N/M marker in stderr; pattern works on every platform`
|
||||
|
||||
`DEFECT.STACKED-PR-CANNOT-STAND-ALONE.symptom=patch PR was authored against scaffolding (handler files, lint scripts, generated modules) that exists only on an unmerged upstream feature branch; the PR's "base" on GitHub is the feature branch, not main; merging requires the upstream PR to land first`
|
||||
`DEFECT.STACKED-PR-CANNOT-STAND-ALONE.examples=#3639 + #3637 both targeted base=feat/3575-enforcement-hardening (the Phase 6 PR #3577); #3639 modifies SDK-bridge calls in 6 family-router files that on main do NOT have any SDK-bridge call yet; #3637 patches scripts/lint-shared-module-handsync.cjs which does not exist on main at all`
|
||||
`DEFECT.STACKED-PR-CANNOT-STAND-ALONE.detect=gh pr view <n> --json baseRefName shows non-main base; OR git rebase --onto origin/main <upstream-pr-branch> <patch-pr-branch> produces real (not whitespace) conflicts at files the patch claims to modify; OR git cat-file -e origin/main:<patch-target-file> errors with "does not exist in origin/main"`
|
||||
`DEFECT.STACKED-PR-CANNOT-STAND-ALONE.fix-forward=user policy (this session, 2026-05-16): every PR must stand alone. Resolution = cherry-pick the patch's unique commits onto the upstream PR head, push to upstream PR branch, close patch PR with "subsumed by #<upstream>". Alternatives explicitly rejected: leaving stacked open ("no, fold them in") and closing-without-folding ("we want the fix")`
|
||||
`DEFECT.STACKED-PR-CANNOT-STAND-ALONE.anti-pattern=blindly running git rebase --onto origin/main on the patch branch — produces "conflicts" that are really "the scaffolding doesn't exist yet"; resolving them means reinventing the upstream PR's contribution, which duplicates work and creates merge hazards. Recognize the shape early via cat-file probe before rebasing`
|
||||
|
||||
`DEFECT.CANARY-VERSION-LEAK.symptom=package.json version on main carries a -canary.<N> suffix that per release policy belongs to the dev branch only; nothing publishable depends on the version string at runtime, but every consumer of the version metadata (release flow, install banners, statusline) sees the dev-channel label`
|
||||
`DEFECT.CANARY-VERSION-LEAK.examples=2026-05-16 audit found origin/main + origin/feat/3575-enforcement-hardening both at "version": "1.50.0-canary.0" in sdk/package.json AND root package.json; npm view @gsd-build/sdk versions returned ["0.1.0"] only, dist-tag latest=0.1.0, @1.50.0-canary.0 404 — confirms the string is metadata-only, never published. git log -S '"version": "1.50.0-canary.0"' origin/main blamed commit 2d32ad82 fix(plan-phase)... (#3206), a fix PR that accidentally carried the version bump from a dev-branch base`
|
||||
`DEFECT.CANARY-VERSION-LEAK.detect=jq -r .version package.json sdk/package.json on origin/main shows a -canary suffix; OR npm view <pkg> dist-tags shows latest != main's version`
|
||||
`DEFECT.CANARY-VERSION-LEAK.fix-forward=open a chore/* PR against main that resets the version strings to the canonical pre-canary stable; rebase open PRs to pick it up; gate at PR open with a CI check that rejects -canary versions on PRs targeting main`
|
||||
`DEFECT.CANARY-VERSION-LEAK.cross-ref=RELEASE-NOTES.RELEASE-STREAM.dev-branch=canary dist-tag (only); this defect is the live counter-example to that rule`
|
||||
|
||||
`DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.symptom=pick_host succeeds at probe time (ssh -o ConnectTimeout=3 -o BatchMode=yes "$h" true); subsequent ssh "$h" 'docker run ...' hangs indefinitely because the chosen host went unreachable between probe and exec; gsd-test-summary buffers stderr until the wrapper exits, so the operator sees no progress at all`
|
||||
`DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.examples=2026-05-16 redshirt probed up at 12:48 UTC, gsd-test-summary picked it, docker container spawned, then redshirt's ssh daemon stopped responding — banner-exchange timeout. Test stalled 20+ minutes with the wrapper's output file at 0 bytes`
|
||||
`DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.detect=gsd-test-summary's task output file at /private/tmp/claude-*/tasks/<id>.output stays 0 bytes for >5 min after launch; ps shows the test still alive; ssh -o ConnectTimeout=5 <probed-host> true now times out`
|
||||
`DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.fix-forward=TaskStop the wrapper; pkill -f gsd-test-summary + pkill -f "ssh <dead-host>"; re-run gsd-test-summary so pick_host re-randomizes from the live set (probe each ~/.config/gsd-test/hosts entry first to confirm). Upstream fix candidate: gsd-test should add a heartbeat read on the ssh-stdin channel and abort + retry on a different host after N silent seconds`
|
||||
`DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.related=DEFECT.GSD-TEST-MIRROR-POISONED (legacy bind-mount ownership); GSD-TEST-CONCURRENT-OUTPUT-COLLISION (file collision) — host-mid-run-death is the third independent gsd-test infra failure mode this month`
|
||||
|
||||
`RULESET.HARNESS.test-memory-guard=~/.claude/hooks/test-memory-guard.sh fires on every Bash PreToolUse; if argv[0]∈{node|vitest|jest|mocha|tsx|ts-node|tap|ava|playwright|cypress} OR matches (npm|pnpm|yarn|bun) (run )?(t|test|tests|vitest|jest); blocks via hookSpecificOutput.permissionDecision=deny when sum(RSS of running matching procs, excluding tsserver|*-mcp|claude|Electron|...) ≥ 4 GiB OR when argv[0] basename matches a running process's argv[0]. Exception: node --version|-v|--help|-h|-p|-e are trivial probes and skip the check. Designed for a 24 GB Mac where prior accidental fan-out exhausted RAM`
|
||||
|
||||
`RULESET.PR-FLOW.docker-before-push=before ANY git push of any fix to any PR, run gsd-test-summary (docker on the remote, mirrors ubuntu CI) and confirm exit 0. macOS-local node --test is NOT a substitute — many failures are platform-specific (path separators, case sensitivity, locale, fs semantics). Watchdog with Monitor on the output log; never set a sleep/timer and walk away. Source: user feedback 2026-05-16 — "we don't set a timer we actively watch and record results in real time as possible"`
|
||||
|
||||
`RULESET.PR-FLOW.templates-mandatory=every gh pr create|edit|gh issue create|edit MUST first invoke the gh-templates-first skill and Read (Read tool, not Bash cat — k321 read-tracking) the matching template in .github/. Apply ALL required sections; never write freeform bodies. Repo enforces this via gsd-pr-template-policy GitHub Action which flags any non-templated body — the bot allows the PR to stay open only because authors are contributors-or-higher, but the warning is a real complaint that must be cured. Source: user feedback 2026-05-16 (multi-message escalation) — "the whole reason i have that github action is because you fucking blow through and ignore using the templates"`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -202,7 +202,7 @@ Track coverage as you go.
|
||||
**Integer phases (1, 2, 3):** Planned milestone work.
|
||||
|
||||
**Decimal phases (2.1, 2.2):** Urgent insertions after planning.
|
||||
- Created via `/gsd:phase insert`
|
||||
- Created via `/gsd:phase --insert`
|
||||
- Execute between integers: 1 → 1.1 → 1.2 → 2
|
||||
|
||||
**Starting number:**
|
||||
|
||||
@@ -13,7 +13,7 @@ Parse the command arguments:
|
||||
- First argument: integer phase number to insert after
|
||||
- Remaining arguments: phase description
|
||||
|
||||
Example: `/gsd-insert-phase 72 Fix critical auth bug`
|
||||
Example: `/gsd:phase --insert 72 Fix critical auth bug`
|
||||
-> after = 72
|
||||
-> description = "Fix critical auth bug"
|
||||
|
||||
@@ -21,8 +21,8 @@ If arguments missing:
|
||||
|
||||
```
|
||||
ERROR: Both phase number and description required
|
||||
Usage: /gsd-insert-phase <after> <description>
|
||||
Example: /gsd-insert-phase 72 Fix critical auth bug
|
||||
Usage: /gsd:phase --insert <after> <description>
|
||||
Example: /gsd:phase --insert 72 Fix critical auth bug
|
||||
```
|
||||
|
||||
Exit.
|
||||
|
||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -516,9 +516,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
|
||||
"integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1167,9 +1167,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.18",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz",
|
||||
"integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==",
|
||||
"version": "4.12.19",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.19.tgz",
|
||||
"integrity": "sha512-xa3eYXYXx68XTT4hZ7dRzsXBhaq85ToSrlUJNoR0gwz/1Ap/CNwX47wfvV7pc/xWhjKVVkLT7zBJy8chhNguqQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
|
||||
@@ -614,3 +614,175 @@ describe('initManager workstream (#2731)', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// initProgress + initManager precedence (#2674)
|
||||
//
|
||||
// Both handlers must agree on phase status given the same inputs. Specifically,
|
||||
// a ROADMAP `- [x] Phase N` checkbox wins over disk state: a stub phase dir
|
||||
// with no SUMMARY.md that is checked in ROADMAP reports as `complete` from
|
||||
// both handlers.
|
||||
//
|
||||
// Pre-fix: initManager reported `complete` (explicit override), initProgress
|
||||
// reported `pending` (disk-only policy). This mismatch meant /gsd-manager and
|
||||
// /gsd-progress disagreed on the same data. Post-fix: both apply the
|
||||
// ROADMAP-[x]-wins policy.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const PRECEDENCE_CONFIG = JSON.stringify({
|
||||
model_profile: 'balanced',
|
||||
commit_docs: false,
|
||||
git: {
|
||||
branching_strategy: 'none',
|
||||
phase_branch_template: 'gsd/phase-{phase}-{slug}',
|
||||
milestone_branch_template: 'gsd/{milestone}-{slug}',
|
||||
quick_branch_template: null,
|
||||
},
|
||||
workflow: { research: true, plan_check: true, verifier: true, nyquist_validation: true },
|
||||
});
|
||||
|
||||
const PRECEDENCE_STATE = [
|
||||
'---',
|
||||
'milestone: v1.0',
|
||||
'---',
|
||||
].join('\n');
|
||||
|
||||
/** Find a phase by numeric value regardless of zero-padding ('3' vs '03'). */
|
||||
function findPhaseByNum(
|
||||
phases: Record<string, unknown>[],
|
||||
num: number,
|
||||
): Record<string, unknown> | undefined {
|
||||
return phases.find(p => parseInt(p.number as string, 10) === num);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a ROADMAP.md with the given phase list. Each entry is
|
||||
* `{num, name, checked}`. Emits both the checkbox summary lines AND the
|
||||
* `### Phase N:` heading sections (so initManager picks them up).
|
||||
*/
|
||||
async function writePrecedenceRoadmap(
|
||||
dir: string,
|
||||
phases: Array<{ num: string; name: string; checked: boolean }>,
|
||||
): Promise<void> {
|
||||
const checkboxes = phases
|
||||
.map(p => `- [${p.checked ? 'x' : ' '}] Phase ${p.num}: ${p.name}`)
|
||||
.join('\n');
|
||||
const sections = phases
|
||||
.map(p => `### Phase ${p.num}: ${p.name}\n\n**Goal:** ${p.name} goal\n\n**Depends on:** None\n`)
|
||||
.join('\n');
|
||||
await writeFile(join(dir, '.planning', 'ROADMAP.md'), [
|
||||
'# Roadmap',
|
||||
'',
|
||||
'## v1.0: Test',
|
||||
'',
|
||||
checkboxes,
|
||||
'',
|
||||
sections,
|
||||
].join('\n'));
|
||||
}
|
||||
|
||||
describe('initProgress + initManager precedence (#2674)', () => {
|
||||
let precedenceDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
precedenceDir = await mkdtemp(join(tmpdir(), 'gsd-2674-'));
|
||||
await mkdir(join(precedenceDir, '.planning', 'phases'), { recursive: true });
|
||||
await writeFile(join(precedenceDir, '.planning', 'config.json'), PRECEDENCE_CONFIG);
|
||||
await writeFile(join(precedenceDir, '.planning', 'STATE.md'), PRECEDENCE_STATE);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(precedenceDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('case 1: ROADMAP [x] + stub phase dir + no SUMMARY → both report complete', async () => {
|
||||
await writePrecedenceRoadmap(precedenceDir, [{ num: '3', name: 'Stubbed', checked: true }]);
|
||||
await mkdir(join(precedenceDir, '.planning', 'phases', '03-stubbed'), { recursive: true });
|
||||
// stub dir, no PLAN/SUMMARY/RESEARCH/CONTEXT files
|
||||
|
||||
const progress = (await initProgress([], precedenceDir)).data as Record<string, unknown>;
|
||||
const manager = (await initManager([], precedenceDir)).data as Record<string, unknown>;
|
||||
|
||||
const pPhase = findPhaseByNum(progress.phases as Record<string, unknown>[], 3);
|
||||
const mPhase = findPhaseByNum(manager.phases as Record<string, unknown>[], 3);
|
||||
|
||||
expect(pPhase?.status).toBe('complete');
|
||||
expect(mPhase?.disk_status).toBe('complete');
|
||||
});
|
||||
|
||||
it('case 2: ROADMAP [x] + phase dir + SUMMARY present → both complete (sanity)', async () => {
|
||||
await writePrecedenceRoadmap(precedenceDir, [{ num: '3', name: 'Done', checked: true }]);
|
||||
await mkdir(join(precedenceDir, '.planning', 'phases', '03-done'), { recursive: true });
|
||||
await writeFile(join(precedenceDir, '.planning', 'phases', '03-done', '03-01-PLAN.md'), '# plan');
|
||||
await writeFile(join(precedenceDir, '.planning', 'phases', '03-done', '03-01-SUMMARY.md'), '# done');
|
||||
|
||||
const progress = (await initProgress([], precedenceDir)).data as Record<string, unknown>;
|
||||
const manager = (await initManager([], precedenceDir)).data as Record<string, unknown>;
|
||||
|
||||
const pPhase = findPhaseByNum(progress.phases as Record<string, unknown>[], 3);
|
||||
const mPhase = findPhaseByNum(manager.phases as Record<string, unknown>[], 3);
|
||||
|
||||
expect(pPhase?.status).toBe('complete');
|
||||
expect(mPhase?.disk_status).toBe('complete');
|
||||
});
|
||||
|
||||
it('case 3: ROADMAP [ ] + phase dir + SUMMARY present → disk authoritative (complete)', async () => {
|
||||
await writePrecedenceRoadmap(precedenceDir, [{ num: '3', name: 'Disk', checked: false }]);
|
||||
await mkdir(join(precedenceDir, '.planning', 'phases', '03-disk'), { recursive: true });
|
||||
await writeFile(join(precedenceDir, '.planning', 'phases', '03-disk', '03-01-PLAN.md'), '# plan');
|
||||
await writeFile(join(precedenceDir, '.planning', 'phases', '03-disk', '03-01-SUMMARY.md'), '# done');
|
||||
|
||||
const progress = (await initProgress([], precedenceDir)).data as Record<string, unknown>;
|
||||
const manager = (await initManager([], precedenceDir)).data as Record<string, unknown>;
|
||||
|
||||
const pPhase = findPhaseByNum(progress.phases as Record<string, unknown>[], 3);
|
||||
const mPhase = findPhaseByNum(manager.phases as Record<string, unknown>[], 3);
|
||||
|
||||
expect(pPhase?.status).toBe('complete');
|
||||
expect(mPhase?.disk_status).toBe('complete');
|
||||
});
|
||||
|
||||
it('case 4: ROADMAP [ ] + stub phase dir + no SUMMARY → not complete', async () => {
|
||||
await writePrecedenceRoadmap(precedenceDir, [{ num: '3', name: 'Empty', checked: false }]);
|
||||
await mkdir(join(precedenceDir, '.planning', 'phases', '03-empty'), { recursive: true });
|
||||
|
||||
const progress = (await initProgress([], precedenceDir)).data as Record<string, unknown>;
|
||||
const manager = (await initManager([], precedenceDir)).data as Record<string, unknown>;
|
||||
|
||||
const pPhase = findPhaseByNum(progress.phases as Record<string, unknown>[], 3);
|
||||
const mPhase = findPhaseByNum(manager.phases as Record<string, unknown>[], 3);
|
||||
|
||||
// Neither should be 'complete' — preserves pre-existing classification.
|
||||
expect(pPhase?.status).not.toBe('complete');
|
||||
expect(mPhase?.disk_status).not.toBe('complete');
|
||||
});
|
||||
|
||||
it('case 5: ROADMAP [x] + no phase dir → both complete (ROADMAP-only branch preserved)', async () => {
|
||||
await writePrecedenceRoadmap(precedenceDir, [{ num: '3', name: 'Paper', checked: true }]);
|
||||
// no directory for phase 3
|
||||
|
||||
const progress = (await initProgress([], precedenceDir)).data as Record<string, unknown>;
|
||||
const manager = (await initManager([], precedenceDir)).data as Record<string, unknown>;
|
||||
|
||||
const pPhase = findPhaseByNum(progress.phases as Record<string, unknown>[], 3);
|
||||
const mPhase = findPhaseByNum(manager.phases as Record<string, unknown>[], 3);
|
||||
|
||||
expect(pPhase?.status).toBe('complete');
|
||||
expect(mPhase?.disk_status).toBe('complete');
|
||||
});
|
||||
|
||||
it('case 6: completed_count agrees across handlers for the stub-dir [x] case', async () => {
|
||||
await writePrecedenceRoadmap(precedenceDir, [
|
||||
{ num: '3', name: 'Stub', checked: true },
|
||||
{ num: '4', name: 'Todo', checked: false },
|
||||
]);
|
||||
await mkdir(join(precedenceDir, '.planning', 'phases', '03-stub'), { recursive: true });
|
||||
await mkdir(join(precedenceDir, '.planning', 'phases', '04-todo'), { recursive: true });
|
||||
|
||||
const progress = (await initProgress([], precedenceDir)).data as Record<string, unknown>;
|
||||
const manager = (await initManager([], precedenceDir)).data as Record<string, unknown>;
|
||||
|
||||
expect(progress.completed_count).toBe(1);
|
||||
expect(manager.completed_count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
/**
|
||||
* Regression guard for #2674.
|
||||
*
|
||||
* initProgress and initManager must agree on phase status given the same
|
||||
* inputs. Specifically, a ROADMAP `- [x] Phase N` checkbox wins over disk
|
||||
* state: a stub phase directory with no SUMMARY.md that is checked in
|
||||
* ROADMAP reports as `complete` from both handlers.
|
||||
*
|
||||
* Pre-fix: initManager reported `complete` (explicit override at line ~451),
|
||||
* initProgress reported `pending` (disk-only policy). This mismatch meant
|
||||
* /gsd-manager and /gsd-progress disagreed on the same data. Post-fix:
|
||||
* both apply the ROADMAP-[x]-wins policy.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { initProgress, initManager } from './init-complex.js';
|
||||
|
||||
/** Find a phase by numeric value regardless of zero-padding ('3' vs '03'). */
|
||||
function findPhase(
|
||||
phases: Record<string, unknown>[],
|
||||
num: number,
|
||||
): Record<string, unknown> | undefined {
|
||||
return phases.find(p => parseInt(p.number as string, 10) === num);
|
||||
}
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
const CONFIG = JSON.stringify({
|
||||
model_profile: 'balanced',
|
||||
commit_docs: false,
|
||||
git: {
|
||||
branching_strategy: 'none',
|
||||
phase_branch_template: 'gsd/phase-{phase}-{slug}',
|
||||
milestone_branch_template: 'gsd/{milestone}-{slug}',
|
||||
quick_branch_template: null,
|
||||
},
|
||||
workflow: { research: true, plan_check: true, verifier: true, nyquist_validation: true },
|
||||
});
|
||||
|
||||
const STATE = [
|
||||
'---',
|
||||
'milestone: v1.0',
|
||||
'---',
|
||||
].join('\n');
|
||||
|
||||
/**
|
||||
* Write a ROADMAP.md with the given phase list. Each entry is
|
||||
* `{num, name, checked}`. Emits both the checkbox summary lines AND the
|
||||
* `### Phase N:` heading sections (so initManager picks them up).
|
||||
*/
|
||||
async function writeRoadmap(
|
||||
dir: string,
|
||||
phases: Array<{ num: string; name: string; checked: boolean }>,
|
||||
): Promise<void> {
|
||||
const checkboxes = phases
|
||||
.map(p => `- [${p.checked ? 'x' : ' '}] Phase ${p.num}: ${p.name}`)
|
||||
.join('\n');
|
||||
const sections = phases
|
||||
.map(p => `### Phase ${p.num}: ${p.name}\n\n**Goal:** ${p.name} goal\n\n**Depends on:** None\n`)
|
||||
.join('\n');
|
||||
await writeFile(join(dir, '.planning', 'ROADMAP.md'), [
|
||||
'# Roadmap',
|
||||
'',
|
||||
'## v1.0: Test',
|
||||
'',
|
||||
checkboxes,
|
||||
'',
|
||||
sections,
|
||||
].join('\n'));
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-2674-'));
|
||||
await mkdir(join(tmpDir, '.planning', 'phases'), { recursive: true });
|
||||
await writeFile(join(tmpDir, '.planning', 'config.json'), CONFIG);
|
||||
await writeFile(join(tmpDir, '.planning', 'STATE.md'), STATE);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('initProgress + initManager precedence (#2674)', () => {
|
||||
it('case 1: ROADMAP [x] + stub phase dir + no SUMMARY → both report complete', async () => {
|
||||
await writeRoadmap(tmpDir, [{ num: '3', name: 'Stubbed', checked: true }]);
|
||||
await mkdir(join(tmpDir, '.planning', 'phases', '03-stubbed'), { recursive: true });
|
||||
// stub dir, no PLAN/SUMMARY/RESEARCH/CONTEXT files
|
||||
|
||||
const progress = (await initProgress([], tmpDir)).data as Record<string, unknown>;
|
||||
const manager = (await initManager([], tmpDir)).data as Record<string, unknown>;
|
||||
|
||||
const pPhase = findPhase(progress.phases as Record<string, unknown>[], 3);
|
||||
const mPhase = findPhase(manager.phases as Record<string, unknown>[], 3);
|
||||
|
||||
expect(pPhase?.status).toBe('complete');
|
||||
expect(mPhase?.disk_status).toBe('complete');
|
||||
});
|
||||
|
||||
it('case 2: ROADMAP [x] + phase dir + SUMMARY present → both complete (sanity)', async () => {
|
||||
await writeRoadmap(tmpDir, [{ num: '3', name: 'Done', checked: true }]);
|
||||
await mkdir(join(tmpDir, '.planning', 'phases', '03-done'), { recursive: true });
|
||||
await writeFile(join(tmpDir, '.planning', 'phases', '03-done', '03-01-PLAN.md'), '# plan');
|
||||
await writeFile(join(tmpDir, '.planning', 'phases', '03-done', '03-01-SUMMARY.md'), '# done');
|
||||
|
||||
const progress = (await initProgress([], tmpDir)).data as Record<string, unknown>;
|
||||
const manager = (await initManager([], tmpDir)).data as Record<string, unknown>;
|
||||
|
||||
const pPhase = findPhase(progress.phases as Record<string, unknown>[], 3);
|
||||
const mPhase = findPhase(manager.phases as Record<string, unknown>[], 3);
|
||||
|
||||
expect(pPhase?.status).toBe('complete');
|
||||
expect(mPhase?.disk_status).toBe('complete');
|
||||
});
|
||||
|
||||
it('case 3: ROADMAP [ ] + phase dir + SUMMARY present → disk authoritative (complete)', async () => {
|
||||
await writeRoadmap(tmpDir, [{ num: '3', name: 'Disk', checked: false }]);
|
||||
await mkdir(join(tmpDir, '.planning', 'phases', '03-disk'), { recursive: true });
|
||||
await writeFile(join(tmpDir, '.planning', 'phases', '03-disk', '03-01-PLAN.md'), '# plan');
|
||||
await writeFile(join(tmpDir, '.planning', 'phases', '03-disk', '03-01-SUMMARY.md'), '# done');
|
||||
|
||||
const progress = (await initProgress([], tmpDir)).data as Record<string, unknown>;
|
||||
const manager = (await initManager([], tmpDir)).data as Record<string, unknown>;
|
||||
|
||||
const pPhase = findPhase(progress.phases as Record<string, unknown>[], 3);
|
||||
const mPhase = findPhase(manager.phases as Record<string, unknown>[], 3);
|
||||
|
||||
expect(pPhase?.status).toBe('complete');
|
||||
expect(mPhase?.disk_status).toBe('complete');
|
||||
});
|
||||
|
||||
it('case 4: ROADMAP [ ] + stub phase dir + no SUMMARY → not complete', async () => {
|
||||
await writeRoadmap(tmpDir, [{ num: '3', name: 'Empty', checked: false }]);
|
||||
await mkdir(join(tmpDir, '.planning', 'phases', '03-empty'), { recursive: true });
|
||||
|
||||
const progress = (await initProgress([], tmpDir)).data as Record<string, unknown>;
|
||||
const manager = (await initManager([], tmpDir)).data as Record<string, unknown>;
|
||||
|
||||
const pPhase = findPhase(progress.phases as Record<string, unknown>[], 3);
|
||||
const mPhase = findPhase(manager.phases as Record<string, unknown>[], 3);
|
||||
|
||||
// Neither should be 'complete' — preserves pre-existing classification.
|
||||
expect(pPhase?.status).not.toBe('complete');
|
||||
expect(mPhase?.disk_status).not.toBe('complete');
|
||||
});
|
||||
|
||||
it('case 5: ROADMAP [x] + no phase dir → both complete (ROADMAP-only branch preserved)', async () => {
|
||||
await writeRoadmap(tmpDir, [{ num: '3', name: 'Paper', checked: true }]);
|
||||
// no directory for phase 3
|
||||
|
||||
const progress = (await initProgress([], tmpDir)).data as Record<string, unknown>;
|
||||
const manager = (await initManager([], tmpDir)).data as Record<string, unknown>;
|
||||
|
||||
const pPhase = findPhase(progress.phases as Record<string, unknown>[], 3);
|
||||
const mPhase = findPhase(manager.phases as Record<string, unknown>[], 3);
|
||||
|
||||
expect(pPhase?.status).toBe('complete');
|
||||
expect(mPhase?.disk_status).toBe('complete');
|
||||
});
|
||||
|
||||
it('case 6: completed_count agrees across handlers for the stub-dir [x] case', async () => {
|
||||
await writeRoadmap(tmpDir, [
|
||||
{ num: '3', name: 'Stub', checked: true },
|
||||
{ num: '4', name: 'Todo', checked: false },
|
||||
]);
|
||||
await mkdir(join(tmpDir, '.planning', 'phases', '03-stub'), { recursive: true });
|
||||
await mkdir(join(tmpDir, '.planning', 'phases', '04-todo'), { recursive: true });
|
||||
|
||||
const progress = (await initProgress([], tmpDir)).data as Record<string, unknown>;
|
||||
const manager = (await initManager([], tmpDir)).data as Record<string, unknown>;
|
||||
|
||||
expect(progress.completed_count).toBe(1);
|
||||
expect(manager.completed_count).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -757,16 +757,15 @@ describe('initListWorkspaces', () => {
|
||||
});
|
||||
|
||||
describe('initRemoveWorkspace', () => {
|
||||
it('returns error when name arg missing', async () => {
|
||||
const result = await initRemoveWorkspace([], tmpDir);
|
||||
const data = result.data as Record<string, unknown>;
|
||||
expect(data.error).toBeDefined();
|
||||
// initRemoveWorkspace throws GSDError for validation failures rather than
|
||||
// returning { data: { error } } — the CLI output path treats error-returns as
|
||||
// success, so validation must surface as a thrown exception for non-zero exit.
|
||||
it('throws GSDError when name arg missing', async () => {
|
||||
await expect(initRemoveWorkspace([], tmpDir)).rejects.toThrow('workspace name required');
|
||||
});
|
||||
|
||||
it('rejects path separator in workspace name (T-14-01)', async () => {
|
||||
const result = await initRemoveWorkspace(['../../bad'], tmpDir);
|
||||
const data = result.data as Record<string, unknown>;
|
||||
expect(data.error).toBeDefined();
|
||||
it('throws GSDError for path separator in workspace name (T-14-01)', async () => {
|
||||
await expect(initRemoveWorkspace(['../../bad'], tmpDir)).rejects.toThrow('path separators not allowed');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -164,16 +164,34 @@ function computeExpectedPhaseDirName(
|
||||
async function shouldDropArchivedPhaseMatch(
|
||||
phaseInfo: Record<string, unknown> | null,
|
||||
roadmapPhase: Record<string, unknown> | null,
|
||||
_projectDir: string,
|
||||
_workstream?: string,
|
||||
projectDir: string,
|
||||
workstream?: string,
|
||||
): Promise<boolean> {
|
||||
// Matches CJS cmdInitPlanPhase / cmdInitExecutePhase / cmdInitVerifyWork:
|
||||
// if (phaseInfo?.archived && roadmapPhase?.found) phaseInfo = null;
|
||||
// Unconditional drop — the ROADMAP is authoritative for the current milestone,
|
||||
// regardless of what archived milestone the on-disk match came from. Do NOT add
|
||||
// a milestone-version equality check (#2391 regression risk).
|
||||
// ROADMAP is authoritative for the current milestone, regardless of what
|
||||
// archived milestone the on-disk match came from — BUT see #3469 exception.
|
||||
if (!phaseInfo?.archived) return false;
|
||||
if (!roadmapPhase || !roadmapPhase.found) return false;
|
||||
|
||||
// #3469: If the archived phase belongs to the CURRENT milestone (e.g. phases
|
||||
// were cleared via `phases clear` into .planning/milestones/<version>-phases/
|
||||
// but the workflow is still on that same milestone), preserve the archived dir
|
||||
// as the canonical phase location. Only drop when the archived version is from
|
||||
// a PRIOR milestone (the original anti-ghost-phase guard).
|
||||
// Note: the milestone-version equality check here does NOT regress #2391
|
||||
// because in the #2391 scenario archived.version != current milestone.
|
||||
const pp = planningPaths(projectDir, workstream);
|
||||
try {
|
||||
const stateContent = readFileSync(pp.state, 'utf-8');
|
||||
const milestoneMatch = stateContent.match(/^milestone:\s*(.+)$/m);
|
||||
const currentMilestone = milestoneMatch ? milestoneMatch[1].trim() : null;
|
||||
if (currentMilestone && phaseInfo.archived === currentMilestone) {
|
||||
// Same milestone — archived dir is the canonical location. Keep it.
|
||||
return false;
|
||||
}
|
||||
} catch { /* STATE.md unreadable — fall through to default drop */ }
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
/**
|
||||
* Tests for bug #2267: deps_satisfied should include phases from shipped milestones.
|
||||
*
|
||||
* Root cause: completedNums was built only from the current milestone's phases,
|
||||
* so a dependency on a phase from a previously shipped milestone was never
|
||||
* satisfied — even though all prior-milestone phases are complete by definition.
|
||||
*/
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
describe('init manager — cross-milestone dependency satisfaction', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
/**
|
||||
* Write a ROADMAP.md that has:
|
||||
* - A shipped previous milestone (v1.0) inside a <details> block containing
|
||||
* Phase 5 marked [x] complete.
|
||||
* - A current active milestone (v2.0) containing Phase 6 that depends on
|
||||
* Phase 5.
|
||||
*/
|
||||
function writeRoadmapWithShippedMilestone(dir) {
|
||||
const content = [
|
||||
'# Roadmap',
|
||||
'',
|
||||
'<details>',
|
||||
'<summary>v1.0 — Initial Release (Shipped)</summary>',
|
||||
'',
|
||||
'## Roadmap v1.0: Initial Release',
|
||||
'',
|
||||
'- [x] **Phase 5: Auth**',
|
||||
'',
|
||||
'### Phase 5: Auth',
|
||||
'**Goal:** Add authentication',
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
'## Roadmap v2.0: Dashboard',
|
||||
'',
|
||||
'- [ ] **Phase 6: Dashboard**',
|
||||
'',
|
||||
'### Phase 6: Dashboard',
|
||||
'**Goal:** Build dashboard',
|
||||
'**Depends on:** Phase 5',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
fs.writeFileSync(path.join(dir, '.planning', 'ROADMAP.md'), content);
|
||||
}
|
||||
|
||||
function writeStateWithMilestone(dir, version) {
|
||||
fs.writeFileSync(
|
||||
path.join(dir, '.planning', 'STATE.md'),
|
||||
`---\nmilestone: ${version}\n---\n# State\n`
|
||||
);
|
||||
}
|
||||
|
||||
test('phase depending on a shipped-milestone phase has deps_satisfied: true', () => {
|
||||
writeRoadmapWithShippedMilestone(tmpDir);
|
||||
writeStateWithMilestone(tmpDir, 'v2.0');
|
||||
|
||||
const result = runGsdTools('init manager', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
|
||||
// Only the current milestone's phases should appear in the phases array
|
||||
assert.strictEqual(output.phases.length, 1, 'Should have exactly one phase from the current milestone');
|
||||
|
||||
const phase6 = output.phases[0];
|
||||
assert.strictEqual(phase6.number, '6', 'Should be Phase 6');
|
||||
|
||||
// Phase 6 depends on Phase 5 from the prior milestone — must be satisfied
|
||||
assert.strictEqual(
|
||||
phase6.deps_satisfied,
|
||||
true,
|
||||
'Phase 6 dep on shipped Phase 5 should be satisfied'
|
||||
);
|
||||
});
|
||||
|
||||
test('phase depending on a non-existent phase has deps_satisfied: false', () => {
|
||||
writeRoadmapWithShippedMilestone(tmpDir);
|
||||
writeStateWithMilestone(tmpDir, 'v2.0');
|
||||
|
||||
// Add a second phase in the current milestone that depends on a phantom phase
|
||||
const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md');
|
||||
const existing = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
const withExtra = existing + [
|
||||
'### Phase 7: Extra',
|
||||
'**Goal:** Extra work',
|
||||
'**Depends on:** Phase 99',
|
||||
'',
|
||||
].join('\n');
|
||||
fs.writeFileSync(roadmapPath, withExtra);
|
||||
|
||||
const result = runGsdTools('init manager', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
const phase7 = output.phases.find(p => p.number === '7');
|
||||
assert.ok(phase7, 'Phase 7 should be in the output');
|
||||
|
||||
assert.strictEqual(
|
||||
phase7.deps_satisfied,
|
||||
false,
|
||||
'Phase 7 dep on non-existent Phase 99 should not be satisfied'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -596,3 +596,118 @@ describe('init manager', () => {
|
||||
assert.ok(output.recommended_actions.length > 0, 'recommended_actions should not be empty when non-backlog phases remain');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Bug #2267: deps_satisfied should include phases from shipped milestones.
|
||||
//
|
||||
// Root cause: completedNums was built only from the current milestone's phases,
|
||||
// so a dependency on a phase from a previously shipped milestone was never
|
||||
// satisfied — even though all prior-milestone phases are complete by definition.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('init manager — cross-milestone dependency satisfaction (#2267)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
/**
|
||||
* Write a ROADMAP.md that has:
|
||||
* - A shipped previous milestone (v1.0) inside a <details> block containing
|
||||
* Phase 5 marked [x] complete.
|
||||
* - A current active milestone (v2.0) containing Phase 6 that depends on
|
||||
* Phase 5.
|
||||
*/
|
||||
function writeRoadmapWithShippedMilestone(dir) {
|
||||
const content = [
|
||||
'# Roadmap',
|
||||
'',
|
||||
'<details>',
|
||||
'<summary>v1.0 — Initial Release (Shipped)</summary>',
|
||||
'',
|
||||
'## Roadmap v1.0: Initial Release',
|
||||
'',
|
||||
'- [x] **Phase 5: Auth**',
|
||||
'',
|
||||
'### Phase 5: Auth',
|
||||
'**Goal:** Add authentication',
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
'## Roadmap v2.0: Dashboard',
|
||||
'',
|
||||
'- [ ] **Phase 6: Dashboard**',
|
||||
'',
|
||||
'### Phase 6: Dashboard',
|
||||
'**Goal:** Build dashboard',
|
||||
'**Depends on:** Phase 5',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
fs.writeFileSync(path.join(dir, '.planning', 'ROADMAP.md'), content);
|
||||
}
|
||||
|
||||
function writeStateWithMilestone(dir, version) {
|
||||
fs.writeFileSync(
|
||||
path.join(dir, '.planning', 'STATE.md'),
|
||||
`---\nmilestone: ${version}\n---\n# State\n`
|
||||
);
|
||||
}
|
||||
|
||||
test('phase depending on a shipped-milestone phase has deps_satisfied: true (#2267)', () => {
|
||||
writeRoadmapWithShippedMilestone(tmpDir);
|
||||
writeStateWithMilestone(tmpDir, 'v2.0');
|
||||
|
||||
const result = runGsdTools('init manager', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
|
||||
// Only the current milestone's phases should appear in the phases array
|
||||
assert.strictEqual(output.phases.length, 1, 'Should have exactly one phase from the current milestone');
|
||||
|
||||
const phase6 = output.phases[0];
|
||||
assert.strictEqual(phase6.number, '6', 'Should be Phase 6');
|
||||
|
||||
// Phase 6 depends on Phase 5 from the prior milestone — must be satisfied
|
||||
assert.strictEqual(
|
||||
phase6.deps_satisfied,
|
||||
true,
|
||||
'Phase 6 dep on shipped Phase 5 should be satisfied'
|
||||
);
|
||||
});
|
||||
|
||||
test('phase depending on a non-existent phase has deps_satisfied: false (#2267)', () => {
|
||||
writeRoadmapWithShippedMilestone(tmpDir);
|
||||
writeStateWithMilestone(tmpDir, 'v2.0');
|
||||
|
||||
// Add a second phase in the current milestone that depends on a phantom phase
|
||||
const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md');
|
||||
const existing = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
const withExtra = existing + [
|
||||
'### Phase 7: Extra',
|
||||
'**Goal:** Extra work',
|
||||
'**Depends on:** Phase 99',
|
||||
'',
|
||||
].join('\n');
|
||||
fs.writeFileSync(roadmapPath, withExtra);
|
||||
|
||||
const result = runGsdTools('init manager', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
const phase7 = output.phases.find(p => p.number === '7');
|
||||
assert.ok(phase7, 'Phase 7 should be in the output');
|
||||
|
||||
assert.strictEqual(
|
||||
phase7.deps_satisfied,
|
||||
false,
|
||||
'Phase 7 dep on non-existent Phase 99 should not be satisfied'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user