test(#1971): consolidate 48 gsd-tools CLI regression tests into subcommand suites
Fold 48 issue-named gsd-tools CLI regression files into the canonical test file that owns each subcommand subject (state, roadmap, phase, milestone, audit, config, router/dispatch, stats, verify, health, etc.), preserving every assertion and its origin issue number as provenance (block-scoped describe wrappers, 299 subtests conserved 1:1). No monolithic gsd-tools.test.cjs created — routes into 18 existing per-subject suites. Removes 48 tests/ files. Regenerates regression-name allowlist (271->231), ratchets the file-count allowlist across 6 buckets (audit/milestone/phase/roadmap/state/verify), and makes 10 relocated allow-test-rule exemptions issue-ref-compliant (ADR-456; prunes 10 stale ids). Repoints one CONTEXT.md symptom ref and ADR-3524's parity-test ref. lint:ci green. Part of epic #1969. Closes #1971. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -931,7 +931,7 @@ Full detail in `~/.claude/skills/gsd-pr-fix-discipline/SKILL.md`. AI agents MUST
|
|||||||
|
|
||||||
### Slash command two-tier confusion
|
### Slash command two-tier confusion
|
||||||
|
|
||||||
- **Symptom:** `tests/bug-2543-gsd-slash-namespace.test.cjs` or `tests/bug-3584-runtime-slash-emitters.test.cjs` fails
|
- **Symptom:** `tests/bug-2543-gsd-slash-namespace.test.cjs` or `tests/init-manager.test.cjs` (folds former `bug-3584-runtime-slash-emitters`, consolidation epic #1969) fails
|
||||||
- **Affected this session:** #154 (three passes), #164 (added the authoritative matrix)
|
- **Affected this session:** #154 (three passes), #164 (added the authoritative matrix)
|
||||||
- **Fix:** Consult `## Slash-command form` section of this file before touching any `/gsd-` or `/gsd:` token — colon for `agents/`/`commands/`, hyphen for runtime emitters
|
- **Fix:** Consult `## Slash-command form` section of this file before touching any `/gsd-` or `/gsd:` token — colon for `agents/`/`commands/`, hyphen for runtime emitters
|
||||||
|
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ parsing from `sdk/dist/query/validate.js`, the same technique used for `escapeRe
|
|||||||
`gen-phase-lifecycle-policy.mjs`. The function is deterministic and pure: no closures over external
|
`gen-phase-lifecycle-policy.mjs`. The function is deterministic and pure: no closures over external
|
||||||
state, no side effects.
|
state, no side effects.
|
||||||
|
|
||||||
**Parity tests:** `tests/6-validate-cjs-drift-regression.test.cjs` — 5 tests (all GREEN after fix,
|
**Parity tests:** `tests/health-validation.test.cjs` (folds former `6-validate-cjs-drift-regression`, consolidation epic #1969) — 5 tests (all GREEN after fix,
|
||||||
all RED on pre-fix `origin/main`). Covers each drift item with concrete fixtures:
|
all RED on pre-fix `origin/main`). Covers each drift item with concrete fixtures:
|
||||||
- Drift 1: two milestone archives (v1.0 old, v1.1 active); v1.0 phase absent from ROADMAP.
|
- Drift 1: two milestone archives (v1.0 old, v1.1 active); v1.0 phase absent from ROADMAP.
|
||||||
- Drift 2: ROADMAP "01A", disk "1A-foo" — padding mismatch.
|
- Drift 2: ROADMAP "01A", disk "1A-foo" — padding mismatch.
|
||||||
|
|||||||
@@ -25,7 +25,6 @@
|
|||||||
"tests/bug-214-phase-researcher-write-truncation-contract.test.cjs :: source-text-is-the-product",
|
"tests/bug-214-phase-researcher-write-truncation-contract.test.cjs :: source-text-is-the-product",
|
||||||
"tests/bug-214-writer-agents-write-truncation-contract.test.cjs :: source-text-is-the-product",
|
"tests/bug-214-writer-agents-write-truncation-contract.test.cjs :: source-text-is-the-product",
|
||||||
"tests/bug-222-research-synthesizer-write-contract.test.cjs :: source-text-is-the-product",
|
"tests/bug-222-research-synthesizer-write-contract.test.cjs :: source-text-is-the-product",
|
||||||
"tests/bug-224-pick-stdout-capture.test.cjs :: structural-implementation-guard",
|
|
||||||
"tests/bug-2346-agent-read-loop-guards.test.cjs :: source-text-is-the-product",
|
"tests/bug-2346-agent-read-loop-guards.test.cjs :: source-text-is-the-product",
|
||||||
"tests/bug-2388-plan-phase-no-branch-rename.test.cjs :: source-text-is-the-product",
|
"tests/bug-2388-plan-phase-no-branch-rename.test.cjs :: source-text-is-the-product",
|
||||||
"tests/bug-2396-makefile-test-priority.test.cjs :: source-text-is-the-product",
|
"tests/bug-2396-makefile-test-priority.test.cjs :: source-text-is-the-product",
|
||||||
@@ -41,7 +40,6 @@
|
|||||||
"tests/bug-2543-gsd-slash-namespace.test.cjs :: structural-regression-guard",
|
"tests/bug-2543-gsd-slash-namespace.test.cjs :: structural-regression-guard",
|
||||||
"tests/bug-2549-2550-2552-discuss-phase-context.test.cjs :: source-text-is-the-product",
|
"tests/bug-2549-2550-2552-discuss-phase-context.test.cjs :: source-text-is-the-product",
|
||||||
"tests/bug-2559-stale-search-year.test.cjs :: source-text-is-the-product",
|
"tests/bug-2559-stale-search-year.test.cjs :: source-text-is-the-product",
|
||||||
"tests/bug-2661-roadmap-sync-parallel.test.cjs :: source-text-is-the-product",
|
|
||||||
"tests/bug-2686-review-fix-worktree.test.cjs :: source-text-is-the-product",
|
"tests/bug-2686-review-fix-worktree.test.cjs :: source-text-is-the-product",
|
||||||
"tests/bug-2770-annotate-deps-int-coerce.test.cjs :: source-text-is-the-product",
|
"tests/bug-2770-annotate-deps-int-coerce.test.cjs :: source-text-is-the-product",
|
||||||
"tests/bug-2772-gitmodules-path-intersection.test.cjs :: source-text-is-the-product",
|
"tests/bug-2772-gitmodules-path-intersection.test.cjs :: source-text-is-the-product",
|
||||||
@@ -53,7 +51,6 @@
|
|||||||
"tests/bug-2954-help-md-slash-command-stubs.test.cjs :: source-text-is-the-product",
|
"tests/bug-2954-help-md-slash-command-stubs.test.cjs :: source-text-is-the-product",
|
||||||
"tests/bug-2973-profile-user-skills-path.test.cjs :: source-text-is-the-product. profile-user.md IS the",
|
"tests/bug-2973-profile-user-skills-path.test.cjs :: source-text-is-the-product. profile-user.md IS the",
|
||||||
"tests/bug-2990-code-fixer-worktree-branch.test.cjs :: source-text-is-the-product",
|
"tests/bug-2990-code-fixer-worktree-branch.test.cjs :: source-text-is-the-product",
|
||||||
"tests/bug-3086-git-create-tag-config-gate.test.cjs :: workflow-markdown-is-the-runtime-contract",
|
|
||||||
"tests/bug-3096-ai-integration-phase-parallel-race.test.cjs :: reads product workflow markdown (ai-integration-phase.md) to verify structural ordering contract — not a source-grep test",
|
"tests/bug-3096-ai-integration-phase-parallel-race.test.cjs :: reads product workflow markdown (ai-integration-phase.md) to verify structural ordering contract — not a source-grep test",
|
||||||
"tests/bug-3097-3099-executor-worktree-path-safety.test.cjs :: reads markdown product files (gsd-executor.md, worktree-path-safety.md) to verify structural protocol — not source-grep",
|
"tests/bug-3097-3099-executor-worktree-path-safety.test.cjs :: reads markdown product files (gsd-executor.md, worktree-path-safety.md) to verify structural protocol — not source-grep",
|
||||||
"tests/bug-3120-secure-phase-empty-register.test.cjs :: reads product workflow markdown (secure-phase.md) to verify structural guard contract — not a source-grep test",
|
"tests/bug-3120-secure-phase-empty-register.test.cjs :: reads product workflow markdown (secure-phase.md) to verify structural guard contract — not a source-grep test",
|
||||||
@@ -75,7 +72,6 @@
|
|||||||
"tests/bug-3430-planner-phase-contract.test.cjs :: source-text-is-the-product",
|
"tests/bug-3430-planner-phase-contract.test.cjs :: source-text-is-the-product",
|
||||||
"tests/bug-3431-debug-command-yaml.test.cjs :: source-text-is-the-product",
|
"tests/bug-3431-debug-command-yaml.test.cjs :: source-text-is-the-product",
|
||||||
"tests/bug-3446-resume-continue-here-discovery.test.cjs :: source-text-is-the-product",
|
"tests/bug-3446-resume-continue-here-discovery.test.cjs :: source-text-is-the-product",
|
||||||
"tests/bug-3489-complete-phase-idempotent.test.cjs :: source-text-is-the-product",
|
|
||||||
"tests/bug-3491-nested-git-worktree.test.cjs :: source-text-is-the-product",
|
"tests/bug-3491-nested-git-worktree.test.cjs :: source-text-is-the-product",
|
||||||
"tests/bug-3516-reapply-patches-gsd-update-filter.test.cjs :: source-text-is-the-product",
|
"tests/bug-3516-reapply-patches-gsd-update-filter.test.cjs :: source-text-is-the-product",
|
||||||
"tests/bug-3521-quick-cleanup-cwd-pin.test.cjs :: source-text-is-the-product",
|
"tests/bug-3521-quick-cleanup-cwd-pin.test.cjs :: source-text-is-the-product",
|
||||||
@@ -91,7 +87,6 @@
|
|||||||
"tests/bug-3683-command-cross-reference-invariant.test.cjs :: source-text-is-the-product",
|
"tests/bug-3683-command-cross-reference-invariant.test.cjs :: source-text-is-the-product",
|
||||||
"tests/bug-3683-workflow-colon-namespace-leak.test.cjs :: source-text-is-the-product",
|
"tests/bug-3683-workflow-colon-namespace-leak.test.cjs :: source-text-is-the-product",
|
||||||
"tests/bug-3689-resume-glob-nomatch.test.cjs :: source-text-is-the-product",
|
"tests/bug-3689-resume-glob-nomatch.test.cjs :: source-text-is-the-product",
|
||||||
"tests/bug-3691-annotate-deps-plans-block-variants.test.cjs :: source-text-is-the-product",
|
|
||||||
"tests/bug-3706-ui-safety-gate-false-positives.test.cjs :: source-text-is-the-product",
|
"tests/bug-3706-ui-safety-gate-false-positives.test.cjs :: source-text-is-the-product",
|
||||||
"tests/bug-3707-locked-worktree-cleanup.test.cjs :: source-text-is-the-product",
|
"tests/bug-3707-locked-worktree-cleanup.test.cjs :: source-text-is-the-product",
|
||||||
"tests/bug-3727-code-review-fix-flag-dispatch.test.cjs :: source-text-is-the-product",
|
"tests/bug-3727-code-review-fix-flag-dispatch.test.cjs :: source-text-is-the-product",
|
||||||
@@ -99,7 +94,6 @@
|
|||||||
"tests/bug-3784-gsd-settings-model-profile-ui-omits-adaptive.test.cjs :: source-text-is-the-product",
|
"tests/bug-3784-gsd-settings-model-profile-ui-omits-adaptive.test.cjs :: source-text-is-the-product",
|
||||||
"tests/bug-3805-fast-md-log-to-state-schema.test.cjs :: source-text-is-the-product",
|
"tests/bug-3805-fast-md-log-to-state-schema.test.cjs :: source-text-is-the-product",
|
||||||
"tests/bug-3810-no-gsd-sdk-runtime-refs.test.cjs :: source-text-is-the-product",
|
"tests/bug-3810-no-gsd-sdk-runtime-refs.test.cjs :: source-text-is-the-product",
|
||||||
"tests/bug-397-state-preserve-executor-authored.test.cjs :: reads runtime STATE.md written to temp dir — behavioral output test, not source-grep",
|
|
||||||
"tests/bug-444-resolver-local-claude-install.test.cjs :: structural/behavioral regression for the repo-local .claude/ install",
|
"tests/bug-444-resolver-local-claude-install.test.cjs :: structural/behavioral regression for the repo-local .claude/ install",
|
||||||
"tests/bug-474-clock-seam-date-determinism.test.cjs :: source-text-is-the-product",
|
"tests/bug-474-clock-seam-date-determinism.test.cjs :: source-text-is-the-product",
|
||||||
"tests/bug-503-update-agent-antigravity-detection.test.cjs :: source-text-is-the-product",
|
"tests/bug-503-update-agent-antigravity-detection.test.cjs :: source-text-is-the-product",
|
||||||
@@ -116,8 +110,6 @@
|
|||||||
"tests/bug-936-no-nested-spawner-wrap.test.cjs :: source-text-is-the-product",
|
"tests/bug-936-no-nested-spawner-wrap.test.cjs :: source-text-is-the-product",
|
||||||
"tests/bug-947-hermes-gsd-prefix.test.cjs :: source-text-is-the-product",
|
"tests/bug-947-hermes-gsd-prefix.test.cjs :: source-text-is-the-product",
|
||||||
"tests/bug-950-quick-summary-status-complete.test.cjs :: source-text-is-the-product",
|
"tests/bug-950-quick-summary-status-complete.test.cjs :: source-text-is-the-product",
|
||||||
"tests/bug-967-verify-key-links-strict-paths.test.cjs :: <runtime-contract-is-the-product> the plan-md.md reference",
|
|
||||||
"tests/bug-967-verify-key-links-strict-paths.test.cjs :: <runtime-contract-is-the-product> the plan-md.md reference example IS the documented authoring surface for key_links; asserting it uses a file path (not an endpoint) directly tests the documented contract.",
|
|
||||||
"tests/bug-patterns-reference.test.cjs :: source-text-is-the-product",
|
"tests/bug-patterns-reference.test.cjs :: source-text-is-the-product",
|
||||||
"tests/chain-flag-plan-phase.test.cjs :: source-text-is-the-product",
|
"tests/chain-flag-plan-phase.test.cjs :: source-text-is-the-product",
|
||||||
"tests/changeset-cli.test.cjs :: reads a product workflow .md file (not CJS source) to verify",
|
"tests/changeset-cli.test.cjs :: reads a product workflow .md file (not CJS source) to verify",
|
||||||
@@ -162,7 +154,6 @@
|
|||||||
"tests/enh-2430-learnings-consumption.test.cjs :: source-text-is-the-product",
|
"tests/enh-2430-learnings-consumption.test.cjs :: source-text-is-the-product",
|
||||||
"tests/enh-2433-todo-phase-linking.test.cjs :: source-text-is-the-product",
|
"tests/enh-2433-todo-phase-linking.test.cjs :: source-text-is-the-product",
|
||||||
"tests/enh-2446-milestones-drift.test.cjs :: source-text-is-the-product",
|
"tests/enh-2446-milestones-drift.test.cjs :: source-text-is-the-product",
|
||||||
"tests/enh-2447-roadmap-wave-deps.test.cjs :: source-text-is-the-product",
|
|
||||||
"tests/enh-2448-artifact-registry.test.cjs :: source-text-is-the-product",
|
"tests/enh-2448-artifact-registry.test.cjs :: source-text-is-the-product",
|
||||||
"tests/enh-2500-codebase-mapper-arch-rich-format.test.cjs :: source-text-is-the-product",
|
"tests/enh-2500-codebase-mapper-arch-rich-format.test.cjs :: source-text-is-the-product",
|
||||||
"tests/enh-2789-description-budget.test.cjs :: source-text-is-the-product",
|
"tests/enh-2789-description-budget.test.cjs :: source-text-is-the-product",
|
||||||
@@ -191,7 +182,6 @@
|
|||||||
"tests/feat-22-surfacing-docs.test.cjs :: docs-parity",
|
"tests/feat-22-surfacing-docs.test.cjs :: docs-parity",
|
||||||
"tests/feat-2840-issue-driven-orchestration-guide.test.cjs :: structural-IR parser for a docs guide. The .includes()",
|
"tests/feat-2840-issue-driven-orchestration-guide.test.cjs :: structural-IR parser for a docs guide. The .includes()",
|
||||||
"tests/feat-3039-help-tiered.test.cjs :: source-text-is-the-product",
|
"tests/feat-3039-help-tiered.test.cjs :: source-text-is-the-product",
|
||||||
"tests/feat-3167-ship-pr-body-sections.test.cjs :: source-text-is-the-product",
|
|
||||||
"tests/feat-3210-fallow-integration.test.cjs :: source-text-is-the-product",
|
"tests/feat-3210-fallow-integration.test.cjs :: source-text-is-the-product",
|
||||||
"tests/feat-3210-fallow-integration.test.cjs :: source-text-is-the-product — code-review.md IS the workflow the orchestrator",
|
"tests/feat-3210-fallow-integration.test.cjs :: source-text-is-the-product — code-review.md IS the workflow the orchestrator",
|
||||||
"tests/feat-3309-human-verify-mode.test.cjs :: source-text-is-the-product",
|
"tests/feat-3309-human-verify-mode.test.cjs :: source-text-is-the-product",
|
||||||
|
|||||||
@@ -3,14 +3,9 @@
|
|||||||
"bug-131-release-tarball-smoke-explicit-home.test.cjs",
|
"bug-131-release-tarball-smoke-explicit-home.test.cjs",
|
||||||
"bug-1367-claude-local-flat-command-layout.test.cjs",
|
"bug-1367-claude-local-flat-command-layout.test.cjs",
|
||||||
"bug-14-progress-auto-flag-dropped.test.cjs",
|
"bug-14-progress-auto-flag-dropped.test.cjs",
|
||||||
"bug-167-query-meta-command.test.cjs",
|
|
||||||
"bug-1695-state-patch-clobbers-phase-name.test.cjs",
|
"bug-1695-state-patch-clobbers-phase-name.test.cjs",
|
||||||
"bug-17-askuserquestion-option-cap.test.cjs",
|
"bug-17-askuserquestion-option-cap.test.cjs",
|
||||||
"bug-170-workflow-fallback-install-hint.test.cjs",
|
"bug-170-workflow-fallback-install-hint.test.cjs",
|
||||||
"bug-1760-state-prune-noop-template-field.test.cjs",
|
|
||||||
"bug-1761-state-sync-wrong-progress.test.cjs",
|
|
||||||
"bug-1818-unknown-flags.test.cjs",
|
|
||||||
"bug-1826-phases-clear-confirm.test.cjs",
|
|
||||||
"bug-1829-inherit-model-profile.test.cjs",
|
"bug-1829-inherit-model-profile.test.cjs",
|
||||||
"bug-1834-sh-hooks-installed.test.cjs",
|
"bug-1834-sh-hooks-installed.test.cjs",
|
||||||
"bug-1891-file-resolution.test.cjs",
|
"bug-1891-file-resolution.test.cjs",
|
||||||
@@ -26,8 +21,6 @@
|
|||||||
"bug-214-phase-researcher-write-truncation-contract.test.cjs",
|
"bug-214-phase-researcher-write-truncation-contract.test.cjs",
|
||||||
"bug-214-writer-agents-write-truncation-contract.test.cjs",
|
"bug-214-writer-agents-write-truncation-contract.test.cjs",
|
||||||
"bug-222-research-synthesizer-write-contract.test.cjs",
|
"bug-222-research-synthesizer-write-contract.test.cjs",
|
||||||
"bug-224-pick-stdout-capture.test.cjs",
|
|
||||||
"bug-2268-parallel-discuss.test.cjs",
|
|
||||||
"bug-2344-read-guard-claudecode-env.test.cjs",
|
"bug-2344-read-guard-claudecode-env.test.cjs",
|
||||||
"bug-2346-agent-read-loop-guards.test.cjs",
|
"bug-2346-agent-read-loop-guards.test.cjs",
|
||||||
"bug-2351-intel-kilo-layout.test.cjs",
|
"bug-2351-intel-kilo-layout.test.cjs",
|
||||||
@@ -57,15 +50,10 @@
|
|||||||
"bug-2557-gemini-local-hook-paths.test.cjs",
|
"bug-2557-gemini-local-hook-paths.test.cjs",
|
||||||
"bug-2559-stale-search-year.test.cjs",
|
"bug-2559-stale-search-year.test.cjs",
|
||||||
"bug-260-worktree-path-guard.test.cjs",
|
"bug-260-worktree-path-guard.test.cjs",
|
||||||
"bug-2601-inherit-model-profile.test.cjs",
|
|
||||||
"bug-261-worktree-force-add-guard.test.cjs",
|
"bug-261-worktree-force-add-guard.test.cjs",
|
||||||
"bug-2630-state-frontmatter-milestone-switch.test.cjs",
|
|
||||||
"bug-2638-sub-repos-canonical-location.test.cjs",
|
"bug-2638-sub-repos-canonical-location.test.cjs",
|
||||||
"bug-2659-audit-open-crash.test.cjs",
|
|
||||||
"bug-2660-one-liner-extraction.test.cjs",
|
"bug-2660-one-liner-extraction.test.cjs",
|
||||||
"bug-2661-roadmap-sync-parallel.test.cjs",
|
|
||||||
"bug-2686-review-fix-worktree.test.cjs",
|
"bug-2686-review-fix-worktree.test.cjs",
|
||||||
"bug-2769-requirements-header-variants.test.cjs",
|
|
||||||
"bug-2770-annotate-deps-int-coerce.test.cjs",
|
"bug-2770-annotate-deps-int-coerce.test.cjs",
|
||||||
"bug-2772-gitmodules-path-intersection.test.cjs",
|
"bug-2772-gitmodules-path-intersection.test.cjs",
|
||||||
"bug-2784-update-cache-clear-path.test.cjs",
|
"bug-2784-update-cache-clear-path.test.cjs",
|
||||||
@@ -78,10 +66,8 @@
|
|||||||
"bug-2851-workflow-bare-gsd-tools.test.cjs",
|
"bug-2851-workflow-bare-gsd-tools.test.cjs",
|
||||||
"bug-2866-codex-strip-no-trailing-newline.test.cjs",
|
"bug-2866-codex-strip-no-trailing-newline.test.cjs",
|
||||||
"bug-2876-skill-frontmatter-quote.test.cjs",
|
"bug-2876-skill-frontmatter-quote.test.cjs",
|
||||||
"bug-2911-audit-open-output-shape.test.cjs",
|
|
||||||
"bug-2912-progress-context-authority.test.cjs",
|
"bug-2912-progress-context-authority.test.cjs",
|
||||||
"bug-2916-handle-branching-default-base.test.cjs",
|
"bug-2916-handle-branching-default-base.test.cjs",
|
||||||
"bug-2942-detect-custom-skills.test.cjs",
|
|
||||||
"bug-2943-config-get-context-window-default.test.cjs",
|
"bug-2943-config-get-context-window-default.test.cjs",
|
||||||
"bug-2948-spike-wrap-up-dispatch.test.cjs",
|
"bug-2948-spike-wrap-up-dispatch.test.cjs",
|
||||||
"bug-2949-sketch-wrap-up-dispatch.test.cjs",
|
"bug-2949-sketch-wrap-up-dispatch.test.cjs",
|
||||||
@@ -99,7 +85,6 @@
|
|||||||
"bug-3054-stale-gsd-next-references.test.cjs",
|
"bug-3054-stale-gsd-next-references.test.cjs",
|
||||||
"bug-3072-optional-sketch-findings-guard.test.cjs",
|
"bug-3072-optional-sketch-findings-guard.test.cjs",
|
||||||
"bug-3083-resume-route-clear.test.cjs",
|
"bug-3083-resume-route-clear.test.cjs",
|
||||||
"bug-3086-git-create-tag-config-gate.test.cjs",
|
|
||||||
"bug-3087-planner-directive-language.test.cjs",
|
"bug-3087-planner-directive-language.test.cjs",
|
||||||
"bug-3096-ai-integration-phase-parallel-race.test.cjs",
|
"bug-3096-ai-integration-phase-parallel-race.test.cjs",
|
||||||
"bug-3097-3099-executor-worktree-path-safety.test.cjs",
|
"bug-3097-3099-executor-worktree-path-safety.test.cjs",
|
||||||
@@ -110,22 +95,15 @@
|
|||||||
"bug-3129-validate-commit-git-bypass.test.cjs",
|
"bug-3129-validate-commit-git-bypass.test.cjs",
|
||||||
"bug-3130-update-npx-robust-invocation.test.cjs",
|
"bug-3130-update-npx-robust-invocation.test.cjs",
|
||||||
"bug-3135-capture-backlog-workflow.test.cjs",
|
"bug-3135-capture-backlog-workflow.test.cjs",
|
||||||
"bug-3150-stats-json-decimal-phase-gaps.test.cjs",
|
|
||||||
"bug-3156-plan-phase-opencode-dispatch.test.cjs",
|
"bug-3156-plan-phase-opencode-dispatch.test.cjs",
|
||||||
"bug-3163-codex-agents-md.test.cjs",
|
|
||||||
"bug-3168-task-to-agent-rename.test.cjs",
|
"bug-3168-task-to-agent-rename.test.cjs",
|
||||||
"bug-3195-quick-resurrection-guard.test.cjs",
|
"bug-3195-quick-resurrection-guard.test.cjs",
|
||||||
"bug-3197-gsd-tools-config-whitelist.test.cjs",
|
|
||||||
"bug-321-config-defaults-clone-strategy.test.cjs",
|
"bug-321-config-defaults-clone-strategy.test.cjs",
|
||||||
"bug-3212-execute-phase-stall-safe-resume.test.cjs",
|
"bug-3212-execute-phase-stall-safe-resume.test.cjs",
|
||||||
"bug-3227-config-set-model-overrides.test.cjs",
|
"bug-3227-config-set-model-overrides.test.cjs",
|
||||||
"bug-3236-capture-seed-one-shot.test.cjs",
|
"bug-3236-capture-seed-one-shot.test.cjs",
|
||||||
"bug-3242-state-update-progress-trample.test.cjs",
|
"bug-3242-state-update-progress-trample.test.cjs",
|
||||||
"bug-3243-dotted-command-form.test.cjs",
|
|
||||||
"bug-3257-nested-plans-undercount.test.cjs",
|
|
||||||
"bug-3258-no-stale-gsd-intel-references.test.cjs",
|
"bug-3258-no-stale-gsd-intel-references.test.cjs",
|
||||||
"bug-3275-fmstr-non-string-scalars.test.cjs",
|
|
||||||
"bug-3286-state-write-routing.test.cjs",
|
|
||||||
"bug-3290-intel-updater-layout-block.test.cjs",
|
"bug-3290-intel-updater-layout-block.test.cjs",
|
||||||
"bug-33-settings-model-profile-adaptive.test.cjs",
|
"bug-33-settings-model-profile-adaptive.test.cjs",
|
||||||
"bug-3320-planner-deep-work-rules.test.cjs",
|
"bug-3320-planner-deep-work-rules.test.cjs",
|
||||||
@@ -141,25 +119,19 @@
|
|||||||
"bug-3442-codex-legacy-hooks-json-migration.test.cjs",
|
"bug-3442-codex-legacy-hooks-json-migration.test.cjs",
|
||||||
"bug-3442-shim-projection-drift-guard.test.cjs",
|
"bug-3442-shim-projection-drift-guard.test.cjs",
|
||||||
"bug-3446-resume-continue-here-discovery.test.cjs",
|
"bug-3446-resume-continue-here-discovery.test.cjs",
|
||||||
"bug-3454-state-dollar-backreference-growth.test.cjs",
|
|
||||||
"bug-3489-complete-phase-idempotent.test.cjs",
|
|
||||||
"bug-3491-nested-git-worktree.test.cjs",
|
"bug-3491-nested-git-worktree.test.cjs",
|
||||||
"bug-3509-path-spaces.test.cjs",
|
"bug-3509-path-spaces.test.cjs",
|
||||||
"bug-3516-reapply-patches-gsd-update-filter.test.cjs",
|
"bug-3516-reapply-patches-gsd-update-filter.test.cjs",
|
||||||
"bug-3521-quick-cleanup-cwd-pin.test.cjs",
|
"bug-3521-quick-cleanup-cwd-pin.test.cjs",
|
||||||
"bug-3523-cjs-loadconfig-branching-strategy-warning.test.cjs",
|
"bug-3523-cjs-loadconfig-branching-strategy-warning.test.cjs",
|
||||||
"bug-3537-padded-id-against-unpadded-roadmap.test.cjs",
|
|
||||||
"bug-3541-installer-migration-prompt-user-resolution.test.cjs",
|
"bug-3541-installer-migration-prompt-user-resolution.test.cjs",
|
||||||
"bug-3542-executor-git-stash-prohibition.test.cjs",
|
"bug-3542-executor-git-stash-prohibition.test.cjs",
|
||||||
"bug-3584-runtime-slash-emitters.test.cjs",
|
|
||||||
"bug-3584-runtime-slash-formatter.test.cjs",
|
"bug-3584-runtime-slash-formatter.test.cjs",
|
||||||
"bug-3588-npm-audit-clean.test.cjs",
|
"bug-3588-npm-audit-clean.test.cjs",
|
||||||
"bug-3599-roadmap-get-phase-project-code-prefix.test.cjs",
|
|
||||||
"bug-3605-stale-research-insert-phase-agent-refs.test.cjs",
|
"bug-3605-stale-research-insert-phase-agent-refs.test.cjs",
|
||||||
"bug-3608-antigravity-update-runtime-classification.test.cjs",
|
"bug-3608-antigravity-update-runtime-classification.test.cjs",
|
||||||
"bug-3610-installer-migration-bundled-hooks-classification.test.cjs",
|
"bug-3610-installer-migration-bundled-hooks-classification.test.cjs",
|
||||||
"bug-3628-bundled-hook-classifier-whitelist.test.cjs",
|
"bug-3628-bundled-hook-classifier-whitelist.test.cjs",
|
||||||
"bug-3631-router-raw-flag.test.cjs",
|
|
||||||
"bug-3657-verify-reapply-patches-pristine-drift.test.cjs",
|
"bug-3657-verify-reapply-patches-pristine-drift.test.cjs",
|
||||||
"bug-3659-applysurface-prune-skill-dirs.test.cjs",
|
"bug-3659-applysurface-prune-skill-dirs.test.cjs",
|
||||||
"bug-3668-workflow-runtime-resolution.test.cjs",
|
"bug-3668-workflow-runtime-resolution.test.cjs",
|
||||||
@@ -170,7 +142,6 @@
|
|||||||
"bug-3683-command-cross-reference-invariant.test.cjs",
|
"bug-3683-command-cross-reference-invariant.test.cjs",
|
||||||
"bug-3683-workflow-colon-namespace-leak.test.cjs",
|
"bug-3683-workflow-colon-namespace-leak.test.cjs",
|
||||||
"bug-3689-resume-glob-nomatch.test.cjs",
|
"bug-3689-resume-glob-nomatch.test.cjs",
|
||||||
"bug-3691-annotate-deps-plans-block-variants.test.cjs",
|
|
||||||
"bug-3706-ui-safety-gate-false-positives.test.cjs",
|
"bug-3706-ui-safety-gate-false-positives.test.cjs",
|
||||||
"bug-3707-locked-worktree-cleanup.test.cjs",
|
"bug-3707-locked-worktree-cleanup.test.cjs",
|
||||||
"bug-3727-code-review-fix-flag-dispatch.test.cjs",
|
"bug-3727-code-review-fix-flag-dispatch.test.cjs",
|
||||||
@@ -181,17 +152,12 @@
|
|||||||
"bug-3805-fast-md-log-to-state-schema.test.cjs",
|
"bug-3805-fast-md-log-to-state-schema.test.cjs",
|
||||||
"bug-3810-no-gsd-sdk-runtime-refs.test.cjs",
|
"bug-3810-no-gsd-sdk-runtime-refs.test.cjs",
|
||||||
"bug-384-agents-runtime-aware.test.cjs",
|
"bug-384-agents-runtime-aware.test.cjs",
|
||||||
"bug-397-state-preserve-executor-authored.test.cjs",
|
|
||||||
"bug-416-archive-dir-null.test.cjs",
|
|
||||||
"bug-444-resolver-local-claude-install.test.cjs",
|
"bug-444-resolver-local-claude-install.test.cjs",
|
||||||
"bug-447-gap-analysis-phase-req-ids.test.cjs",
|
"bug-447-gap-analysis-phase-req-ids.test.cjs",
|
||||||
"bug-474-clock-seam-date-determinism.test.cjs",
|
"bug-474-clock-seam-date-determinism.test.cjs",
|
||||||
"bug-492-effort-manifest-fallback.test.cjs",
|
"bug-492-effort-manifest-fallback.test.cjs",
|
||||||
"bug-500-planned-phase-progress-corruption.test.cjs",
|
"bug-500-planned-phase-progress-corruption.test.cjs",
|
||||||
"bug-501-flat-phase-details-milestone-leak.test.cjs",
|
|
||||||
"bug-503-update-agent-antigravity-detection.test.cjs",
|
"bug-503-update-agent-antigravity-detection.test.cjs",
|
||||||
"bug-549-total-phases-overcounts-with-phase-section-heading.test.cjs",
|
|
||||||
"bug-557-details-summary-milestone-strip.test.cjs",
|
|
||||||
"bug-571-doc-writer-fix-mode-edit-only.test.cjs",
|
"bug-571-doc-writer-fix-mode-edit-only.test.cjs",
|
||||||
"bug-580-local-sh-hook-bash-wrapper.test.cjs",
|
"bug-580-local-sh-hook-bash-wrapper.test.cjs",
|
||||||
"bug-619-codebase-drift-gate-shim.test.cjs",
|
"bug-619-codebase-drift-gate-shim.test.cjs",
|
||||||
@@ -205,20 +171,14 @@
|
|||||||
"bug-687-agy-timeout.test.cjs",
|
"bug-687-agy-timeout.test.cjs",
|
||||||
"bug-730-milestone-phase-details-scope.test.cjs",
|
"bug-730-milestone-phase-details-scope.test.cjs",
|
||||||
"bug-783-kilo-global-skills-base.test.cjs",
|
"bug-783-kilo-global-skills-base.test.cjs",
|
||||||
"bug-853-bg-dispatch-runtime-gating.test.cjs",
|
|
||||||
"bug-866-profile-pipeline-temp-root.test.cjs",
|
|
||||||
"bug-891-non-claude-runtime-home-fallback.test.cjs",
|
"bug-891-non-claude-runtime-home-fallback.test.cjs",
|
||||||
"bug-892-validate-checklist-roadmap-phases.test.cjs",
|
"bug-892-validate-checklist-roadmap-phases.test.cjs",
|
||||||
"bug-905-state-syncstatefrontmatter-preserve-scalars.test.cjs",
|
|
||||||
"bug-924-claude-flat-skill-layout.test.cjs",
|
"bug-924-claude-flat-skill-layout.test.cjs",
|
||||||
"bug-925-context-monitor-hook-event-name.test.cjs",
|
"bug-925-context-monitor-hook-event-name.test.cjs",
|
||||||
"bug-936-no-nested-spawner-wrap.test.cjs",
|
"bug-936-no-nested-spawner-wrap.test.cjs",
|
||||||
"bug-941-managed-hooks-registry-manifest.test.cjs",
|
"bug-941-managed-hooks-registry-manifest.test.cjs",
|
||||||
"bug-947-hermes-gsd-prefix.test.cjs",
|
"bug-947-hermes-gsd-prefix.test.cjs",
|
||||||
"bug-948-state-noop-write-guard.test.cjs",
|
|
||||||
"bug-950-quick-summary-status-complete.test.cjs",
|
"bug-950-quick-summary-status-complete.test.cjs",
|
||||||
"bug-967-verify-key-links-strict-paths.test.cjs",
|
|
||||||
"bug-969-test-infra-flake-hardening.test.cjs",
|
"bug-969-test-infra-flake-hardening.test.cjs",
|
||||||
"bug-974-graphify-budget-missing-value.test.cjs",
|
"bug-974-graphify-budget-missing-value.test.cjs"
|
||||||
"bug-978-milestone-complete-force.test.cjs"
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -5,9 +5,7 @@
|
|||||||
"files": [
|
"files": [
|
||||||
"audit-command-cutover.test.cjs",
|
"audit-command-cutover.test.cjs",
|
||||||
"audit-fix-command.test.cjs",
|
"audit-fix-command.test.cjs",
|
||||||
"bug-2659-audit-open-crash.test.cjs",
|
"bug-2836-audit-open-summary-uat-drift.test.cjs"
|
||||||
"bug-2836-audit-open-summary-uat-drift.test.cjs",
|
|
||||||
"bug-2911-audit-open-output-shape.test.cjs"
|
|
||||||
],
|
],
|
||||||
"issue": "192"
|
"issue": "192"
|
||||||
},
|
},
|
||||||
@@ -49,7 +47,6 @@
|
|||||||
"milestone": {
|
"milestone": {
|
||||||
"files": [
|
"files": [
|
||||||
"bug-730-milestone-phase-details-scope.test.cjs",
|
"bug-730-milestone-phase-details-scope.test.cjs",
|
||||||
"bug-978-milestone-complete-force.test.cjs",
|
|
||||||
"milestone-archive.test.cjs",
|
"milestone-archive.test.cjs",
|
||||||
"milestone-helper.test.cjs",
|
"milestone-helper.test.cjs",
|
||||||
"milestone-prefixed-convention.test.cjs",
|
"milestone-prefixed-convention.test.cjs",
|
||||||
@@ -60,7 +57,6 @@
|
|||||||
},
|
},
|
||||||
"phase": {
|
"phase": {
|
||||||
"files": [
|
"files": [
|
||||||
"fix-1437-phase-list-plans.test.cjs",
|
|
||||||
"bug-214-phase-researcher-write-truncation-contract.test.cjs",
|
"bug-214-phase-researcher-write-truncation-contract.test.cjs",
|
||||||
"phase-dependency-levels.test.cjs",
|
"phase-dependency-levels.test.cjs",
|
||||||
"phase.test.cjs"
|
"phase.test.cjs"
|
||||||
@@ -69,10 +65,7 @@
|
|||||||
},
|
},
|
||||||
"roadmap": {
|
"roadmap": {
|
||||||
"files": [
|
"files": [
|
||||||
"bug-2661-roadmap-sync-parallel.test.cjs",
|
|
||||||
"bug-3128-roadmap-plan-count-slug-layout.test.cjs",
|
"bug-3128-roadmap-plan-count-slug-layout.test.cjs",
|
||||||
"bug-3599-roadmap-get-phase-project-code-prefix.test.cjs",
|
|
||||||
"enh-2447-roadmap-wave-deps.test.cjs",
|
|
||||||
"roadmap-mode-field.test.cjs",
|
"roadmap-mode-field.test.cjs",
|
||||||
"roadmap-phase-fallback.test.cjs",
|
"roadmap-phase-fallback.test.cjs",
|
||||||
"roadmap.test.cjs"
|
"roadmap.test.cjs"
|
||||||
@@ -99,17 +92,9 @@
|
|||||||
"state": {
|
"state": {
|
||||||
"files": [
|
"files": [
|
||||||
"bug-1695-state-patch-clobbers-phase-name.test.cjs",
|
"bug-1695-state-patch-clobbers-phase-name.test.cjs",
|
||||||
"bug-1760-state-prune-noop-template-field.test.cjs",
|
|
||||||
"bug-1761-state-sync-wrong-progress.test.cjs",
|
|
||||||
"bug-21-state-md-template-frontmatter.test.cjs",
|
"bug-21-state-md-template-frontmatter.test.cjs",
|
||||||
"bug-2630-state-frontmatter-milestone-switch.test.cjs",
|
|
||||||
"bug-3127-state-begin-phase-idempotent.test.cjs",
|
"bug-3127-state-begin-phase-idempotent.test.cjs",
|
||||||
"bug-3242-state-update-progress-trample.test.cjs",
|
"bug-3242-state-update-progress-trample.test.cjs",
|
||||||
"bug-3286-state-write-routing.test.cjs",
|
|
||||||
"bug-3454-state-dollar-backreference-growth.test.cjs",
|
|
||||||
"bug-397-state-preserve-executor-authored.test.cjs",
|
|
||||||
"bug-905-state-syncstatefrontmatter-preserve-scalars.test.cjs",
|
|
||||||
"bug-948-state-noop-write-guard.test.cjs",
|
|
||||||
"state-acquirestatelock-non-eexist.test.cjs",
|
"state-acquirestatelock-non-eexist.test.cjs",
|
||||||
"state-prune.test.cjs",
|
"state-prune.test.cjs",
|
||||||
"state-rebuild-cli.test.cjs",
|
"state-rebuild-cli.test.cjs",
|
||||||
@@ -124,7 +109,6 @@
|
|||||||
"bug-2994-verify-reapply-patches-installed-path.test.cjs",
|
"bug-2994-verify-reapply-patches-installed-path.test.cjs",
|
||||||
"bug-3381-verify-work-workstream.test.cjs",
|
"bug-3381-verify-work-workstream.test.cjs",
|
||||||
"bug-3657-verify-reapply-patches-pristine-drift.test.cjs",
|
"bug-3657-verify-reapply-patches-pristine-drift.test.cjs",
|
||||||
"bug-967-verify-key-links-strict-paths.test.cjs",
|
|
||||||
"verify-health.test.cjs",
|
"verify-health.test.cjs",
|
||||||
"verify-mvp-uat.test.cjs",
|
"verify-mvp-uat.test.cjs",
|
||||||
"verify-npm-publish.test.cjs",
|
"verify-npm-publish.test.cjs",
|
||||||
|
|||||||
@@ -1,304 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Integration tests for `phase uat-passed <N>` CLI command.
|
|
||||||
* Issue #247 — phase uat-passed predicate
|
|
||||||
*
|
|
||||||
* Tests the full dispatch path: gsd-tools → phase-command-router → phase.cmdPhaseUatPassed
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set up a minimal project with a phase directory and ROADMAP so that
|
|
||||||
* findPhaseInternal(cwd, phaseNum) can resolve it.
|
|
||||||
* Returns { tmpDir, phaseDir }.
|
|
||||||
*/
|
|
||||||
function setupProject(phaseSlug = '01-feature') {
|
|
||||||
const tmpDir = createTempProject();
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
||||||
[
|
|
||||||
'# Roadmap',
|
|
||||||
'',
|
|
||||||
'- [ ] Phase 1: Feature',
|
|
||||||
'',
|
|
||||||
'### Phase 1: Feature',
|
|
||||||
'**Goal:** Build feature',
|
|
||||||
'**Plans:** 1 plans',
|
|
||||||
'',
|
|
||||||
].join('\n'),
|
|
||||||
);
|
|
||||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', phaseSlug);
|
|
||||||
fs.mkdirSync(phaseDir, { recursive: true });
|
|
||||||
return { tmpDir, phaseDir };
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeUatFile(phaseDir, filename, content) {
|
|
||||||
fs.writeFileSync(path.join(phaseDir, filename), content, 'utf-8');
|
|
||||||
}
|
|
||||||
|
|
||||||
function setMtime(filePath, time) {
|
|
||||||
fs.utimesSync(filePath, time, time);
|
|
||||||
}
|
|
||||||
|
|
||||||
function makePassingUat() {
|
|
||||||
return [
|
|
||||||
'---',
|
|
||||||
'status: passed',
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'# UAT Results',
|
|
||||||
'',
|
|
||||||
'### 1. Login works',
|
|
||||||
'expected: User logs in successfully',
|
|
||||||
'result: passed',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function makePendingUat() {
|
|
||||||
return [
|
|
||||||
'---',
|
|
||||||
'status: partial',
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'# UAT Results',
|
|
||||||
'',
|
|
||||||
'### 1. Login works',
|
|
||||||
'expected: User logs in successfully',
|
|
||||||
'result: passed',
|
|
||||||
'',
|
|
||||||
'### 2. Logout works',
|
|
||||||
'expected: User logs out successfully',
|
|
||||||
'result: pending',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeFencedFalsePositiveUat() {
|
|
||||||
// Only "result: passed" lines are inside a fenced block.
|
|
||||||
// The real test has result: pending → should evaluate to passed:false.
|
|
||||||
return [
|
|
||||||
'---',
|
|
||||||
'status: partial',
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'# UAT Results',
|
|
||||||
'',
|
|
||||||
'## Example (do not run)',
|
|
||||||
'```',
|
|
||||||
'### 1. Test',
|
|
||||||
'expected: Example',
|
|
||||||
'result: passed',
|
|
||||||
'```',
|
|
||||||
'',
|
|
||||||
'### 1. Real Test',
|
|
||||||
'expected: The thing works',
|
|
||||||
'result: pending',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Basic pass/fail cases ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('phase uat-passed — basic pass/fail', () => {
|
|
||||||
let tmpDir;
|
|
||||||
let phaseDir;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
({ tmpDir, phaseDir } = setupProject('01-feature'));
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('passing UAT → passed:true with correct JSON shape', () => {
|
|
||||||
writeUatFile(phaseDir, 'feature-UAT.md', makePassingUat());
|
|
||||||
const result = runGsdTools('phase uat-passed 1', tmpDir);
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}\nOutput: ${result.output}`);
|
|
||||||
|
|
||||||
const out = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(out.passed, true);
|
|
||||||
assert.strictEqual(out.phase, '1');
|
|
||||||
assert.ok(Array.isArray(out.uat_files), 'uat_files must be an array');
|
|
||||||
assert.ok(Array.isArray(out.verification_files), 'verification_files must be an array');
|
|
||||||
assert.ok(Array.isArray(out.checks), 'checks must be an array');
|
|
||||||
assert.ok(Array.isArray(out.blockers), 'blockers must be an array');
|
|
||||||
assert.ok(out.policy && typeof out.policy.require_verification === 'boolean',
|
|
||||||
'policy.require_verification must be a boolean');
|
|
||||||
assert.strictEqual(typeof out.no_uat_artifacts, 'boolean', 'no_uat_artifacts must be a boolean');
|
|
||||||
assert.strictEqual(out.no_uat_artifacts, false, 'no_uat_artifacts must be false when checks exist');
|
|
||||||
assert.strictEqual(out.blockers.length, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('pending UAT → passed:false', () => {
|
|
||||||
writeUatFile(phaseDir, 'feature-UAT.md', makePendingUat());
|
|
||||||
const result = runGsdTools('phase uat-passed 1', tmpDir);
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
||||||
|
|
||||||
const out = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(out.passed, false);
|
|
||||||
assert.strictEqual(out.phase, '1');
|
|
||||||
assert.ok(out.blockers.length > 0, 'Should have blockers for pending test');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('false-positive only (fenced block) → passed:false', () => {
|
|
||||||
writeUatFile(phaseDir, 'feature-UAT.md', makeFencedFalsePositiveUat());
|
|
||||||
const result = runGsdTools('phase uat-passed 1', tmpDir);
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
||||||
|
|
||||||
const out = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(out.passed, false,
|
|
||||||
'result:passed inside a fenced block must not flip the predicate to passed');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('no UAT files → passed:false + no_uat_artifacts:true (fail-closed, no vacuous pass)', () => {
|
|
||||||
// Phase directory exists but has no UAT files — fail-closed: absence is NOT a pass
|
|
||||||
const result = runGsdTools('phase uat-passed 1', tmpDir);
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
||||||
|
|
||||||
const out = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(out.passed, false,
|
|
||||||
'Phase with no UAT files must NOT vacuously pass — fail-closed predicate');
|
|
||||||
assert.strictEqual(out.no_uat_artifacts, true,
|
|
||||||
'no_uat_artifacts must be true when no UAT items found');
|
|
||||||
assert.deepStrictEqual(out.uat_files, []);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── --require-verification flag ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('phase uat-passed — --require-verification flag', () => {
|
|
||||||
let tmpDir;
|
|
||||||
let phaseDir;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
({ tmpDir, phaseDir } = setupProject('01-feature'));
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('--require-verification with no verification file → passed:false', () => {
|
|
||||||
writeUatFile(phaseDir, 'feature-UAT.md', makePassingUat());
|
|
||||||
const result = runGsdTools('phase uat-passed 1 --require-verification', tmpDir);
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
||||||
|
|
||||||
const out = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(out.passed, false,
|
|
||||||
'require-verification with no verification file should fail');
|
|
||||||
assert.strictEqual(out.policy.require_verification, true);
|
|
||||||
assert.ok(out.blockers.some(b => /verification required/i.test(b)),
|
|
||||||
`Expected verification-required blocker, got: ${JSON.stringify(out.blockers)}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('--require-verification with passing verification → passed:true', () => {
|
|
||||||
writeUatFile(phaseDir, 'feature-UAT.md', makePassingUat());
|
|
||||||
writeUatFile(phaseDir, 'feature-VERIFICATION.md', '---\nstatus: passed\n---\n\nVerified OK.');
|
|
||||||
const result = runGsdTools('phase uat-passed 1 --require-verification', tmpDir);
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
||||||
|
|
||||||
const out = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(out.passed, true);
|
|
||||||
assert.strictEqual(out.policy.require_verification, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('--require-verification with stale passed verification → passed:false', () => {
|
|
||||||
writeUatFile(phaseDir, 'feature-UAT.md', makePassingUat());
|
|
||||||
const verificationPath = path.join(phaseDir, 'feature-VERIFICATION.md');
|
|
||||||
const summaryPath = path.join(phaseDir, 'feature-SUMMARY.md');
|
|
||||||
writeUatFile(phaseDir, 'feature-VERIFICATION.md', '---\nstatus: passed\n---\n\nVerified OK.');
|
|
||||||
writeUatFile(phaseDir, 'feature-SUMMARY.md', '# Summary\n\nImplementation changed after verification.\n');
|
|
||||||
const now = new Date();
|
|
||||||
setMtime(verificationPath, new Date(now.getTime() - 60_000));
|
|
||||||
setMtime(summaryPath, now);
|
|
||||||
|
|
||||||
const result = runGsdTools('phase uat-passed 1 --require-verification', tmpDir);
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
||||||
|
|
||||||
const out = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(out.passed, false);
|
|
||||||
assert.ok(
|
|
||||||
out.blockers.some(b => /verification status=stale/i.test(b)),
|
|
||||||
`Expected stale-verification blocker, got: ${JSON.stringify(out.blockers)}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('--require-verification with non-canonical complete verification → passed:false', () => {
|
|
||||||
writeUatFile(phaseDir, 'feature-UAT.md', makePassingUat());
|
|
||||||
writeUatFile(phaseDir, 'feature-VERIFICATION.md', '---\nstatus: complete\n---\n\nLegacy OK.');
|
|
||||||
const result = runGsdTools('phase uat-passed 1 --require-verification', tmpDir);
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
||||||
|
|
||||||
const out = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(out.passed, false);
|
|
||||||
assert.ok(out.blockers.some(b => /verification required/i.test(b)),
|
|
||||||
`Expected verification-required blocker, got: ${JSON.stringify(out.blockers)}`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Error cases ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('phase uat-passed — error cases', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = createTempProject();
|
|
||||||
// Write a minimal ROADMAP so phase 1 exists
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
||||||
[
|
|
||||||
'# Roadmap',
|
|
||||||
'',
|
|
||||||
'### Phase 1: Feature',
|
|
||||||
'**Goal:** Build feature',
|
|
||||||
'',
|
|
||||||
].join('\n'),
|
|
||||||
);
|
|
||||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-feature'), { recursive: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('missing phase number → error message', () => {
|
|
||||||
const result = runGsdTools('phase uat-passed', tmpDir);
|
|
||||||
assert.ok(!result.success, 'Should fail with no phase number');
|
|
||||||
assert.ok(
|
|
||||||
result.error.includes('phase number required') ||
|
|
||||||
result.error.includes('Available:'),
|
|
||||||
`Expected phase-number-required error, got: ${result.error}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('unknown phase number → error message', () => {
|
|
||||||
const result = runGsdTools('phase uat-passed 99', tmpDir);
|
|
||||||
assert.ok(!result.success, 'Should fail for unknown phase');
|
|
||||||
assert.ok(
|
|
||||||
result.error.includes('not found') || result.error.includes('99'),
|
|
||||||
`Expected not-found error, got: ${result.error}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('unknown flag (typo --require-verifcation) → InvalidArgs error, not silent pass', () => {
|
|
||||||
const result = runGsdTools('phase uat-passed 1 --require-verifcation', tmpDir);
|
|
||||||
assert.ok(!result.success,
|
|
||||||
'Unknown flag must cause an error, not silently pass');
|
|
||||||
assert.ok(
|
|
||||||
result.error.includes('--require-verifcation') ||
|
|
||||||
result.error.includes('does not support') ||
|
|
||||||
result.error.includes('invalid'),
|
|
||||||
`Expected unknown-flag error, got: ${result.error}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,304 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Regression tests for issue #6 (open-gsd/gsd-core):
|
|
||||||
* Three validation behaviors present in validate.ts are missing from verify.cjs,
|
|
||||||
* producing silent false negatives on the CJS production path.
|
|
||||||
*
|
|
||||||
* Three drift items fixed by porting phaseVariants() and activeDiskPhases to verify.cjs:
|
|
||||||
*
|
|
||||||
* 1. W007 activeDiskPhases — verify.cjs uses diskPhases (includes archived) for the
|
|
||||||
* W007 check; validate.ts uses activeDiskPhases (active phasesDir only). Archived
|
|
||||||
* phases absent from current ROADMAP trigger false W007 in verify.cjs.
|
|
||||||
*
|
|
||||||
* 2. phaseVariants() normalization — validate.ts has a phaseVariants() function
|
|
||||||
* generating padded/unpadded/letter-suffix variants for matching. verify.cjs uses
|
|
||||||
* only parseInt→padded (drops letter suffix), causing false W006/W007 for
|
|
||||||
* letter-suffix phases with padding mismatch between ROADMAP and disk.
|
|
||||||
*
|
|
||||||
* 3. W006 unchecked-phase variant skip — same phaseVariants() gap causes false W006
|
|
||||||
* for phases with padding mismatch: ROADMAP says "3B", disk has "03B-foo", but
|
|
||||||
* verify.cjs padded("3B") = "03" (drops letter) → "03B" on disk not matched.
|
|
||||||
*
|
|
||||||
* References:
|
|
||||||
* - ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md)
|
|
||||||
* - Issue #6 (open-gsd/gsd-core)
|
|
||||||
* - PR #154 (issue #4) — precedent for the generator pattern
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { describe, test, before, after } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
const os = require('node:os');
|
|
||||||
|
|
||||||
const { runGsdTools, cleanup } = require('./helpers.cjs');
|
|
||||||
|
|
||||||
// ── Fixture helpers ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function mkplanning(base) {
|
|
||||||
const planningDir = path.join(base, '.planning');
|
|
||||||
const phasesDir = path.join(planningDir, 'phases');
|
|
||||||
fs.mkdirSync(phasesDir, { recursive: true });
|
|
||||||
return { planningDir, phasesDir };
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeProjectMd(planningDir) {
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(planningDir, 'PROJECT.md'),
|
|
||||||
'# Project\n\n## What This Is\nTest.\n\n## Core Value\nTest.\n\n## Requirements\nTest.\n',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeStateMd(planningDir, phase = '2') {
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(planningDir, 'STATE.md'),
|
|
||||||
`# State\n\n**Current Phase:** ${phase}\n**Status:** In progress\n`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeConfigJson(planningDir) {
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(planningDir, 'config.json'),
|
|
||||||
JSON.stringify({ model_profile: 'balanced' }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Drift Item 1: W007 activeDiskPhases ────────────────────────────────────
|
|
||||||
//
|
|
||||||
// Project has a shipped milestone archive (milestones/v1.0-phases/) with old phase
|
|
||||||
// "1" inside, and active phasesDir with phase "2". Current ROADMAP only mentions
|
|
||||||
// Phase 2 (v1.0 phases were shipped and removed from ROADMAP).
|
|
||||||
//
|
|
||||||
// validate.ts: activeDiskPhases = phases from active phasesDir only (not archived).
|
|
||||||
// "1" is only in diskPhases (via forEachArchivedPhaseToken), not activeDiskPhases.
|
|
||||||
// W007 iterates activeDiskPhases → "1" never checked → no false W007.
|
|
||||||
//
|
|
||||||
// verify.cjs pre-fix: diskPhases = collectDiskPhases() + forEachArchivedPhaseToken().
|
|
||||||
// diskPhases includes "1" (from old archive). W007 iterates diskPhases → "1" not
|
|
||||||
// in roadmapPhases → W007 fires for "1". False positive.
|
|
||||||
|
|
||||||
describe('Drift item 1 — W007 activeDiskPhases: no false W007 for archived phases', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
before(() => {
|
|
||||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-6-d1-'));
|
|
||||||
const { planningDir, phasesDir } = mkplanning(tmpDir);
|
|
||||||
writeProjectMd(planningDir);
|
|
||||||
writeStateMd(planningDir);
|
|
||||||
writeConfigJson(planningDir);
|
|
||||||
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(planningDir, 'ROADMAP.md'),
|
|
||||||
[
|
|
||||||
'# Roadmap',
|
|
||||||
'',
|
|
||||||
'- [x] **Phase 2:** API',
|
|
||||||
'',
|
|
||||||
'### Phase 2: API',
|
|
||||||
'',
|
|
||||||
'## Progress',
|
|
||||||
'| Phase | Status |',
|
|
||||||
'|-------|--------|',
|
|
||||||
'| 2 | Complete |',
|
|
||||||
].join('\n'),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Active phasesDir: only phase 2
|
|
||||||
fs.mkdirSync(path.join(phasesDir, '2-api'), { recursive: true });
|
|
||||||
|
|
||||||
// Current active milestone archive: v1.1-phases contains phase 2 (in ROADMAP)
|
|
||||||
fs.mkdirSync(
|
|
||||||
path.join(planningDir, 'milestones', 'v1.1-phases', '2-api'),
|
|
||||||
{ recursive: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
// Old shipped milestone archive: v1.0-phases contains phase 1 (no longer in ROADMAP)
|
|
||||||
// v1.0 sorts before v1.1 so getActiveMilestoneArchiveDir returns v1.1.
|
|
||||||
// forEachArchivedPhaseToken walks BOTH v1.0 and v1.1, so diskPhases gets "1".
|
|
||||||
// collectDiskPhases (activeDiskPhases) only uses v1.1 (active archive) — "1" excluded.
|
|
||||||
fs.mkdirSync(
|
|
||||||
path.join(planningDir, 'milestones', 'v1.0-phases', '1-foundation'),
|
|
||||||
{ recursive: true },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
after(() => {
|
|
||||||
cleanup(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('no W007 for archived phase "1" absent from current ROADMAP', () => {
|
|
||||||
// validate.ts: activeDiskPhases has only "2" (from active phasesDir + v1.1 archive).
|
|
||||||
// "1" is in diskPhases (forEachArchivedPhaseToken walks v1.0) but NOT activeDiskPhases.
|
|
||||||
// W007 iterates activeDiskPhases → "1" never checked → no false W007. Correct.
|
|
||||||
// verify.cjs pre-fix: diskPhases = collectDiskPhases + forEachArchivedPhaseToken.
|
|
||||||
// diskPhases includes "1" (from v1.0 archive). W007 iterates diskPhases → "1" not
|
|
||||||
// in roadmapPhases → W007 fires for "1". False positive.
|
|
||||||
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
|
|
||||||
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
|
|
||||||
const data = JSON.parse(result.output);
|
|
||||||
const w007 = (data.warnings ?? []).filter((w) => w.code === 'W007');
|
|
||||||
// Filter to W007 that mentions only phase "1" (not "1A", "01A", etc.)
|
|
||||||
const w007Phase1 = w007.filter(
|
|
||||||
(w) => /\bPhase 1\b/i.test(w.message) && !/\b1[A-Z]\b/i.test(w.message),
|
|
||||||
);
|
|
||||||
assert.strictEqual(
|
|
||||||
w007Phase1.length,
|
|
||||||
0,
|
|
||||||
`Expected no W007 for archived phase 1 (v1.0 archive), got: ${JSON.stringify(w007)}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Drift Item 2: phaseVariants() normalization ────────────────────────────
|
|
||||||
//
|
|
||||||
// ROADMAP has "### Phase 01A:" (zero-padded letter-suffix heading).
|
|
||||||
// Disk has directory "1A-foo" (unpadded letter-suffix form).
|
|
||||||
// These should match because phaseVariants("01A") = {"01A", "1A", "01A"}.
|
|
||||||
//
|
|
||||||
// validate.ts: diskPhases has "1A". phaseVariants("01A") includes "1A" → match → no W006.
|
|
||||||
// activeDiskPhases has "1A". phaseVariants("1A") includes "01A" → roadmapPhaseVariants
|
|
||||||
// has "01A" → match → no W007.
|
|
||||||
//
|
|
||||||
// verify.cjs pre-fix:
|
|
||||||
// W006 loop for "01A": padded = String(parseInt("01A",10)).padStart(2,'0') = "01".
|
|
||||||
// diskPhases.has("01A")? NO. diskPhases.has("01")? NO. → W006 fires. Bug.
|
|
||||||
// W007 loop for "1A": unpadded = String(parseInt("1A",10)) = "1".
|
|
||||||
// roadmapPhases.has("1A")? NO. roadmapPhases.has("1")? NO. → W007 fires. Bug.
|
|
||||||
|
|
||||||
describe('Drift item 2 — phaseVariants() normalization: letter-suffix zero-padding mismatch', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
before(() => {
|
|
||||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-6-d2-'));
|
|
||||||
const { planningDir, phasesDir } = mkplanning(tmpDir);
|
|
||||||
writeProjectMd(planningDir);
|
|
||||||
writeStateMd(planningDir);
|
|
||||||
writeConfigJson(planningDir);
|
|
||||||
|
|
||||||
// ROADMAP: Phase 01A (zero-padded + letter suffix)
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(planningDir, 'ROADMAP.md'),
|
|
||||||
[
|
|
||||||
'# Roadmap',
|
|
||||||
'',
|
|
||||||
'- [x] **Phase 01A:** Suffix Phase',
|
|
||||||
'',
|
|
||||||
'### Phase 01A: Suffix Phase',
|
|
||||||
'',
|
|
||||||
'## Progress',
|
|
||||||
'| Phase | Status |',
|
|
||||||
'|-------|--------|',
|
|
||||||
'| 01A | Complete |',
|
|
||||||
].join('\n'),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Disk: unpadded form "1A-foo"
|
|
||||||
fs.mkdirSync(path.join(phasesDir, '1A-suffix-phase'), { recursive: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
after(() => {
|
|
||||||
cleanup(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('no false W006 when ROADMAP says 01A and disk has 1A-... (phaseVariants normalizes)', () => {
|
|
||||||
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
|
|
||||||
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
|
|
||||||
const data = JSON.parse(result.output);
|
|
||||||
const w006 = (data.warnings ?? []).filter((w) => w.code === 'W006');
|
|
||||||
assert.strictEqual(
|
|
||||||
w006.length,
|
|
||||||
0,
|
|
||||||
`Expected no W006 (01A == 1A after normalization), got: ${JSON.stringify(w006)}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('no false W007 when disk has 1A and ROADMAP says 01A (phaseVariants normalizes)', () => {
|
|
||||||
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
|
|
||||||
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
|
|
||||||
const data = JSON.parse(result.output);
|
|
||||||
const w007 = (data.warnings ?? []).filter((w) => w.code === 'W007');
|
|
||||||
assert.strictEqual(
|
|
||||||
w007.length,
|
|
||||||
0,
|
|
||||||
`Expected no W007 (1A on disk matches 01A in ROADMAP), got: ${JSON.stringify(w007)}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Drift Item 3: W006 false positive when disk uses zero-padded letter form ─
|
|
||||||
//
|
|
||||||
// ROADMAP has "### Phase 3B:" (unpadded letter-suffix heading).
|
|
||||||
// Disk has directory "03B-feature" (zero-padded letter-suffix form).
|
|
||||||
//
|
|
||||||
// validate.ts:
|
|
||||||
// diskPhases has "03B". phaseVariants("3B") = {"3B","3B","03B"}.
|
|
||||||
// existsOnDisk = diskPhases.has("03B") = TRUE → no W006.
|
|
||||||
// activeDiskPhases has "03B". phaseVariants("03B") = {"03B","3B","03B"}.
|
|
||||||
// roadmapPhaseVariants has {"3B","3B","03B"} → "03B" found → no W007.
|
|
||||||
//
|
|
||||||
// verify.cjs pre-fix:
|
|
||||||
// W006 for "3B": padded = parseInt("3B")=3 → "03" (drops "B").
|
|
||||||
// diskPhases.has("3B")? NO. diskPhases.has("03")? NO (dir is "03B" not "03"). → W006 fires.
|
|
||||||
// W007 for "03B": unpadded = String(parseInt("03B",10)) = "3".
|
|
||||||
// roadmapPhases.has("03B")? NO. roadmapPhases.has("3")? NO (ROADMAP has "3B" not "3"). → W007.
|
|
||||||
|
|
||||||
describe('Drift item 3 — W006 false positive when disk has zero-padded letter form', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
before(() => {
|
|
||||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-6-d3-'));
|
|
||||||
const { planningDir, phasesDir } = mkplanning(tmpDir);
|
|
||||||
writeProjectMd(planningDir);
|
|
||||||
writeStateMd(planningDir, '3B');
|
|
||||||
writeConfigJson(planningDir);
|
|
||||||
|
|
||||||
// ROADMAP: Phase 3B (unpadded in heading)
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(planningDir, 'ROADMAP.md'),
|
|
||||||
[
|
|
||||||
'# Roadmap',
|
|
||||||
'',
|
|
||||||
'- [x] **Phase 3B:** Feature Extension',
|
|
||||||
'',
|
|
||||||
'### Phase 3B: Feature Extension',
|
|
||||||
'',
|
|
||||||
'## Progress',
|
|
||||||
'| Phase | Status |',
|
|
||||||
'|-------|--------|',
|
|
||||||
'| 3B | Complete |',
|
|
||||||
].join('\n'),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Disk: "03B-feature" (zero-padded letter-suffix form)
|
|
||||||
fs.mkdirSync(path.join(phasesDir, '03B-feature'), { recursive: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
after(() => {
|
|
||||||
cleanup(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('no false W006 when ROADMAP says 3B and disk has 03B-... (phaseVariants covers zero-padded)', () => {
|
|
||||||
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
|
|
||||||
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
|
|
||||||
const data = JSON.parse(result.output);
|
|
||||||
const w006 = (data.warnings ?? []).filter((w) => w.code === 'W006');
|
|
||||||
assert.strictEqual(
|
|
||||||
w006.length,
|
|
||||||
0,
|
|
||||||
`Expected no W006 (3B == 03B after normalization), got: ${JSON.stringify(w006)}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('no false W007 when disk has 03B and ROADMAP says 3B (phaseVariants covers both forms)', () => {
|
|
||||||
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
|
|
||||||
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
|
|
||||||
const data = JSON.parse(result.output);
|
|
||||||
const w007 = (data.warnings ?? []).filter((w) => w.code === 'W007');
|
|
||||||
assert.strictEqual(
|
|
||||||
w007.length,
|
|
||||||
0,
|
|
||||||
`Expected no W007 (03B on disk matches 3B in ROADMAP), got: ${JSON.stringify(w007)}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -379,3 +379,216 @@ describe('audit cutover: registry entries correct', () => {
|
|||||||
assert.strictEqual(entry.router, 'routeGraphifyCommand');
|
assert.strictEqual(entry.router, 'routeGraphifyCommand');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
// Folded from tests/bug-2659-audit-open-crash.test.cjs — consolidation epic #1969 (B2 #1971)
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const { describe: __foldDescribe } = require('node:test');
|
||||||
|
__foldDescribe("folded:bug-2659-audit-open-crash (consolidation epic #1969 B2 #1971)", () => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression test for #2659.
|
||||||
|
*
|
||||||
|
* The `audit-open` dispatch case in bin/gsd-tools.cjs previously called bare
|
||||||
|
* `output(...)` on both the --json and text branches. `output` is never in
|
||||||
|
* local scope — the entire core module is imported as `const core`, so every
|
||||||
|
* other case uses `core.output(...)`. The bare calls therefore crashed with
|
||||||
|
* `ReferenceError: output is not defined` the moment `audit-open` ran.
|
||||||
|
*
|
||||||
|
* This test runs both invocations against a minimal temp project and asserts
|
||||||
|
* they exit successfully with non-empty stdout. It fails with the
|
||||||
|
* ReferenceError on any revision that still has the bare `output(...)` calls.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||||
|
|
||||||
|
describe('audit-open — does not crash with ReferenceError (#2659)', () => {
|
||||||
|
let tmpDir;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = createTempProject('gsd-bug-2659-');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup(tmpDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('audit-open (text output) succeeds and produces stdout', () => {
|
||||||
|
const result = runGsdTools('audit-open', tmpDir);
|
||||||
|
assert.ok(
|
||||||
|
result.success,
|
||||||
|
`audit-open must not crash. stderr: ${result.error}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
!/ReferenceError.*output is not defined/.test(result.error || ''),
|
||||||
|
`audit-open must not throw ReferenceError. stderr: ${result.error}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.output && result.output.length > 0,
|
||||||
|
'audit-open must write a non-empty report to stdout'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('audit-open --json succeeds and produces stdout', () => {
|
||||||
|
const result = runGsdTools(['audit-open', '--json'], tmpDir);
|
||||||
|
assert.ok(
|
||||||
|
result.success,
|
||||||
|
`audit-open --json must not crash. stderr: ${result.error}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
!/ReferenceError.*output is not defined/.test(result.error || ''),
|
||||||
|
`audit-open --json must not throw ReferenceError. stderr: ${result.error}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.output && result.output.length > 0,
|
||||||
|
'audit-open --json must write output to stdout'
|
||||||
|
);
|
||||||
|
let parsed;
|
||||||
|
assert.doesNotThrow(
|
||||||
|
() => { parsed = JSON.parse(result.output); },
|
||||||
|
'audit-open --json must emit valid JSON'
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
parsed !== null && typeof parsed === 'object',
|
||||||
|
'audit-open --json must emit a JSON object or array'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
// Folded from tests/bug-2911-audit-open-output-shape.test.cjs — consolidation epic #1969 (B2 #1971)
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const { describe: __foldDescribe } = require('node:test');
|
||||||
|
__foldDescribe("folded:bug-2911-audit-open-output-shape (consolidation epic #1969 B2 #1971)", () => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression test for #2911.
|
||||||
|
*
|
||||||
|
* Two bugs in the `audit-open` dispatch case in bin/gsd-tools.cjs:
|
||||||
|
*
|
||||||
|
* 1. Bare `output(...)` calls (only `core.output` is in scope) → ReferenceError.
|
||||||
|
* 2. Even after switching to `core.output(formatted, raw)`, the human-readable
|
||||||
|
* branch JSON-stringifies the formatted string because `core.output` only
|
||||||
|
* bypasses JSON encoding when called as `core.output(null, true, rawValue)`.
|
||||||
|
* Result: stdout contains `"━━━…\n Milestone Close: …\n…"` (a JSON string
|
||||||
|
* literal) instead of the rendered report.
|
||||||
|
*
|
||||||
|
* The shape assertions below catch both regressions structurally — never via
|
||||||
|
* substring matching on serialized output:
|
||||||
|
*
|
||||||
|
* - text mode: parse stdout as a sequence of lines and assert the expected
|
||||||
|
* section headers exist as standalone lines (i.e. raw text, not escaped).
|
||||||
|
* If the report is JSON-stringified, the stdout is a single line wrapped
|
||||||
|
* in double quotes with `\n` escapes — line-array assertions fail.
|
||||||
|
* - --json mode: JSON.parse the stdout and assert the keys returned by
|
||||||
|
* `auditOpenArtifacts(cwd)` (scanned_at, has_open_items, counts, items)
|
||||||
|
* are present and well-typed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||||
|
|
||||||
|
describe('audit-open — output shape (#2911)', () => {
|
||||||
|
let tmpDir;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = createTempProject('gsd-bug-2911-');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup(tmpDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('text mode emits the formatted report as raw text (not JSON-encoded)', () => {
|
||||||
|
const result = runGsdTools('audit-open', tmpDir);
|
||||||
|
assert.ok(
|
||||||
|
result.success,
|
||||||
|
`audit-open must not crash. stderr: ${result.error}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const lines = result.output.split('\n').map(l => l.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
// The first non-empty line must be the divider character row, *not* a
|
||||||
|
// JSON-encoded string starting with a quote. If core.output JSON-stringified
|
||||||
|
// the formatted report, the entire payload sits on one line wrapped in
|
||||||
|
// double quotes ("━━━…\n…").
|
||||||
|
assert.ok(
|
||||||
|
!result.output.startsWith('"'),
|
||||||
|
'text-mode stdout must not begin with a JSON quote (would mean the report was JSON.stringified)'
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
!result.output.includes('\\n'),
|
||||||
|
'text-mode stdout must not contain literal "\\n" sequences (would mean the report was JSON.stringified)'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Section headers from formatAuditReport that must appear as standalone lines.
|
||||||
|
assert.ok(
|
||||||
|
lines.includes('Milestone Close: Open Artifact Audit'),
|
||||||
|
`expected report title as a standalone line; got lines: ${JSON.stringify(lines.slice(0, 5))}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
lines.includes('All artifact types clear. Safe to proceed.'),
|
||||||
|
`expected the empty-state line as standalone text; got lines: ${JSON.stringify(lines)}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('--json mode emits parseable JSON matching auditOpenArtifacts shape', () => {
|
||||||
|
const result = runGsdTools(['audit-open', '--json'], tmpDir);
|
||||||
|
assert.ok(
|
||||||
|
result.success,
|
||||||
|
`audit-open --json must not crash. stderr: ${result.error}`
|
||||||
|
);
|
||||||
|
|
||||||
|
let parsed;
|
||||||
|
assert.doesNotThrow(
|
||||||
|
() => { parsed = JSON.parse(result.output); },
|
||||||
|
'audit-open --json must emit valid JSON (not a doubly-stringified string)'
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(typeof parsed, 'object', 'parsed payload must be an object');
|
||||||
|
assert.ok(parsed !== null, 'parsed payload must not be null');
|
||||||
|
|
||||||
|
// Shape contract from auditOpenArtifacts() in gsd-core/bin/lib/audit.cjs.
|
||||||
|
assert.equal(typeof parsed.scanned_at, 'string', 'must include scanned_at ISO timestamp');
|
||||||
|
assert.equal(typeof parsed.has_open_items, 'boolean', 'must include has_open_items boolean');
|
||||||
|
assert.equal(typeof parsed.counts, 'object', 'must include counts object');
|
||||||
|
assert.equal(typeof parsed.items, 'object', 'must include items object');
|
||||||
|
|
||||||
|
const expectedCountKeys = [
|
||||||
|
'debug_sessions', 'quick_tasks', 'threads', 'todos',
|
||||||
|
'seeds', 'uat_gaps', 'verification_gaps', 'context_questions', 'total',
|
||||||
|
];
|
||||||
|
for (const key of expectedCountKeys) {
|
||||||
|
assert.equal(
|
||||||
|
typeof parsed.counts[key], 'number',
|
||||||
|
`counts.${key} must be a number`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedItemKeys = [
|
||||||
|
'debug_sessions', 'quick_tasks', 'threads', 'todos',
|
||||||
|
'seeds', 'uat_gaps', 'verification_gaps', 'context_questions',
|
||||||
|
];
|
||||||
|
for (const key of expectedItemKeys) {
|
||||||
|
assert.ok(
|
||||||
|
Array.isArray(parsed.items[key]),
|
||||||
|
`items.${key} must be an array`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const { test } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
|
|
||||||
const { runGsdTools } = require('./helpers.cjs');
|
|
||||||
|
|
||||||
test('bug #167: query meta-command prefixes direct gsd-tools calls', () => {
|
|
||||||
const direct = runGsdTools(['init.progress']);
|
|
||||||
assert.equal(direct.success, true, `init.progress failed: ${direct.error || direct.output}`);
|
|
||||||
|
|
||||||
const meta = runGsdTools(['query', 'init.progress']);
|
|
||||||
assert.equal(meta.success, true, `query init.progress failed: ${meta.error || meta.output}`);
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
JSON.parse(meta.output),
|
|
||||||
JSON.parse(direct.output),
|
|
||||||
'query-prefixed and direct invocations should return identical init.progress payloads'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
// Regression test for issue #1760 — `state prune` no-ops on template-conformant
|
|
||||||
// STATE.md because it reads `Current Phase` only, never the `Phase: X of Y` line
|
|
||||||
// the canonical template emits.
|
|
||||||
//
|
|
||||||
// ADR-1769 Phase 7 fix: derive the current phase with a `Phase` / `Current Phase`
|
|
||||||
// fallback (mirroring buildStateFrontmatter), so prune engages on template STATE.md.
|
|
||||||
|
|
||||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
|
|
||||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
|
||||||
|
|
||||||
function writeStateMd(tmpDir, content) {
|
|
||||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('#1760: state prune engages on template-conformant STATE.md (Phase: X of Y)', () => {
|
|
||||||
let tmpDir;
|
|
||||||
beforeEach(() => { tmpDir = createTempProject(); });
|
|
||||||
afterEach(() => { cleanup(tmpDir); });
|
|
||||||
|
|
||||||
test('prune engages when STATE.md uses "Phase: N of M" (no Current Phase field)', () => {
|
|
||||||
// Template-conformant STATE.md: Current Position uses `Phase: 10 of 15`, and
|
|
||||||
// there is NO `**Current Phase:**` body field. Pre-fix this made prune bail
|
|
||||||
// with "Only 0 phases — nothing to prune".
|
|
||||||
writeStateMd(tmpDir, [
|
|
||||||
'# Session State',
|
|
||||||
'',
|
|
||||||
'## Current Position',
|
|
||||||
'',
|
|
||||||
'Phase: 10 of 15',
|
|
||||||
'Plan: 2 of 4',
|
|
||||||
'Status: Executing Phase 10',
|
|
||||||
'',
|
|
||||||
'## Decisions',
|
|
||||||
'',
|
|
||||||
'- [Phase 1]: Old decision',
|
|
||||||
'- [Phase 3]: Older decision',
|
|
||||||
'- [Phase 9]: Recent decision',
|
|
||||||
'',
|
|
||||||
].join('\n'));
|
|
||||||
|
|
||||||
const result = runGsdTools('state prune --keep-recent 3 --dry-run', tmpDir);
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
||||||
const out = JSON.parse(result.output);
|
|
||||||
|
|
||||||
// Pre-fix: { pruned: false, reason: 'Only 0 phases — nothing to prune...' }.
|
|
||||||
// Post-fix: prune engages and reports a real cutoff_phase (10 - 3 = 7).
|
|
||||||
assert.strictEqual(out.pruned, false, 'dry-run must report pruned:false');
|
|
||||||
assert.ok(out.reason === undefined || !/Only 0 phases/i.test(String(out.reason)),
|
|
||||||
`prune must not bail with "Only 0 phases" on template STATE.md; got reason=${JSON.stringify(out.reason)}`);
|
|
||||||
assert.strictEqual(out.cutoff_phase, 7,
|
|
||||||
`cutoff_phase must be 7 (current 10 - keep-recent 3); got ${JSON.stringify(out.cutoff_phase)}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('prune still engages when "Current Phase:" IS present (no regression)', () => {
|
|
||||||
writeStateMd(tmpDir, [
|
|
||||||
'# Session State',
|
|
||||||
'',
|
|
||||||
'**Current Phase:** 10',
|
|
||||||
'',
|
|
||||||
'## Decisions',
|
|
||||||
'',
|
|
||||||
'- [Phase 1]: Old decision',
|
|
||||||
'- [Phase 9]: Recent decision',
|
|
||||||
'',
|
|
||||||
].join('\n'));
|
|
||||||
|
|
||||||
const result = runGsdTools('state prune --keep-recent 3 --dry-run', tmpDir);
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
||||||
const out = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(out.cutoff_phase, 7);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
// Regression test for issue #1761 — `state sync` silently writes wrong progress
|
|
||||||
// when ROADMAP lacks versioned milestone headings.
|
|
||||||
//
|
|
||||||
// ADR-1769 Phase 7 fix: when getMilestonePhaseFilter().missingExplicitVersion is
|
|
||||||
// true (the current milestone cannot be bounded to a versioned phase set), the
|
|
||||||
// sync transition leaves Progress untouched (percent=null) rather than silently
|
|
||||||
// computing/writing values off a fallback milestone.
|
|
||||||
|
|
||||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
|
|
||||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
|
||||||
|
|
||||||
function buildStateWithProgress({ percent = 50 } = {}) {
|
|
||||||
const barWidth = 10;
|
|
||||||
const filled = Math.round((percent / 100) * barWidth);
|
|
||||||
const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
|
|
||||||
return [
|
|
||||||
'---',
|
|
||||||
'gsd_state_version: 1.0',
|
|
||||||
'milestone: v1.0',
|
|
||||||
'milestone_name: Test',
|
|
||||||
'current_phase: "3"',
|
|
||||||
'status: executing',
|
|
||||||
'progress:',
|
|
||||||
' total_phases: 10',
|
|
||||||
' completed_phases: 5',
|
|
||||||
` percent: ${percent}`,
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'# GSD State',
|
|
||||||
'',
|
|
||||||
'**Current Phase:** 3',
|
|
||||||
'**Total Plans in Phase:** 4',
|
|
||||||
'**Current Plan:** 2',
|
|
||||||
'**Status:** Executing Phase 3',
|
|
||||||
'**Last Activity:** 2026-06-20',
|
|
||||||
`**Progress:** [${bar}] ${percent}%`,
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ROADMAP with an UNVERSIONED milestone heading (no vX.Y) — the #1761 trigger.
|
|
||||||
function buildUnversionedRoadmap(numPhases) {
|
|
||||||
const lines = ['# ROADMAP', '', '## Milestone 1: Test Milestone', ''];
|
|
||||||
for (let i = 1; i <= numPhases; i++) {
|
|
||||||
lines.push(`### Phase ${i}: phase-${i}`);
|
|
||||||
lines.push('');
|
|
||||||
}
|
|
||||||
return lines.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function readBodyProgress(statePath) {
|
|
||||||
const m = fs.readFileSync(statePath, 'utf-8').match(/\*\*Progress:\*\*\s*(.*)/);
|
|
||||||
return m ? m[1].trim() : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('#1761: state sync leaves Progress untouched when milestone is unbounded', () => {
|
|
||||||
let tmpDir;
|
|
||||||
beforeEach(() => { tmpDir = createTempProject(); });
|
|
||||||
afterEach(() => { cleanup(tmpDir); });
|
|
||||||
|
|
||||||
test('sync does NOT rewrite the Progress bar when ROADMAP lacks a versioned milestone heading', () => {
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
fs.writeFileSync(statePath, buildStateWithProgress({ percent: 50 }));
|
|
||||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), buildUnversionedRoadmap(10));
|
|
||||||
|
|
||||||
// Seed disk: 2 of 10 phases fully summarized → if sync naively recomputes,
|
|
||||||
// it would write ~20%, clobbering the curated 50% (#1761).
|
|
||||||
const phasesDir = path.join(tmpDir, '.planning', 'phases');
|
|
||||||
for (let i = 1; i <= 2; i++) {
|
|
||||||
const dir = path.join(phasesDir, String(i).padStart(2, '0'));
|
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
|
||||||
fs.writeFileSync(path.join(dir, '01-PLAN.md'), '# Plan\n');
|
|
||||||
fs.writeFileSync(path.join(dir, '01-SUMMARY.md'), '# Summary\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
const before = readBodyProgress(statePath);
|
|
||||||
const result = runGsdTools('state sync', tmpDir);
|
|
||||||
assert.ok(result.success, `state sync failed: ${result.error}`);
|
|
||||||
const after = readBodyProgress(statePath);
|
|
||||||
|
|
||||||
assert.strictEqual(after, before,
|
|
||||||
`Progress must be left untouched when the milestone is unbounded; before=${JSON.stringify(before)} after=${JSON.stringify(after)} (#1761)`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// #1761 read-path: the ADR-1769 Phase 7 fix (#1794) closed the `state sync`
|
|
||||||
// WRITE path, but `state json` (the READ path) rebuilds progress via
|
|
||||||
// buildStateFrontmatter, whose roadmapPhaseCount loop counts phase headings
|
|
||||||
// across the WHOLE document when extractCurrentMilestone can't bound the
|
|
||||||
// asserted milestone. Result: state json reported a conflated total_phases
|
|
||||||
// (sum of sibling milestones) + a derived percent, contradicting the sync
|
|
||||||
// guard. This block mirrors the write-path guard on the read path.
|
|
||||||
describe('#1761 read-path: state json does not conflate progress when milestone is unbounded', () => {
|
|
||||||
let tmpDir;
|
|
||||||
beforeEach(() => { tmpDir = createTempProject(); });
|
|
||||||
afterEach(() => { cleanup(tmpDir); });
|
|
||||||
|
|
||||||
test('state json omits percent and does NOT report the conflated whole-doc total_phases', () => {
|
|
||||||
// Repro from the issue: STATE.md asserts milestone: v2.0; ROADMAP has two
|
|
||||||
// UNVERSIONED sibling milestones (4 + 4 phases) — neither matches v2.0, so
|
|
||||||
// the milestone is unbounded. One summarized phase dir on disk.
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
fs.writeFileSync(statePath, [
|
|
||||||
'---',
|
|
||||||
'gsd_state_version: 1.0',
|
|
||||||
'milestone: v2.0',
|
|
||||||
'milestone_name: Second',
|
|
||||||
'current_phase: "2"',
|
|
||||||
'status: executing',
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'# GSD State',
|
|
||||||
'**Current Phase:** 2',
|
|
||||||
'**Status:** Executing Phase 2',
|
|
||||||
'',
|
|
||||||
].join('\n'));
|
|
||||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), [
|
|
||||||
'# ROADMAP',
|
|
||||||
'## Milestone 1: First Milestone',
|
|
||||||
'### Phase 1: a',
|
|
||||||
'### Phase 2: b',
|
|
||||||
'### Phase 3: c',
|
|
||||||
'### Phase 4: d',
|
|
||||||
'## Milestone 2: Second Milestone',
|
|
||||||
'### Phase 5: e',
|
|
||||||
'### Phase 6: f',
|
|
||||||
'### Phase 7: g',
|
|
||||||
'### Phase 8: h',
|
|
||||||
'',
|
|
||||||
].join('\n'));
|
|
||||||
// One summarized phase dir on disk.
|
|
||||||
const dir01 = path.join(tmpDir, '.planning', 'phases', '01');
|
|
||||||
fs.mkdirSync(dir01, { recursive: true });
|
|
||||||
fs.writeFileSync(path.join(dir01, '01-PLAN.md'), '# Plan\n');
|
|
||||||
fs.writeFileSync(path.join(dir01, '01-SUMMARY.md'), '# Summary\n');
|
|
||||||
|
|
||||||
const result = runGsdTools('state json --raw', tmpDir);
|
|
||||||
assert.ok(result.success, `state json failed: ${result.error}`);
|
|
||||||
const out = JSON.parse(result.output);
|
|
||||||
|
|
||||||
// BEFORE the fix this printed progress.total_phases: 8 (4+4 sibling
|
|
||||||
// milestones) and percent: 13 — exactly the conflated read-path the sync
|
|
||||||
// guard was added to prevent.
|
|
||||||
assert.ok(
|
|
||||||
out.progress === undefined || out.progress.percent === undefined,
|
|
||||||
`state json must omit percent when the milestone is unbounded; got progress=${JSON.stringify(out.progress)}`,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
!(out.progress && out.progress.total_phases === 8),
|
|
||||||
`state json must NOT report the conflated whole-doc total_phases (8 = 4+4 sibling milestones); got total_phases=${out.progress && out.progress.total_phases}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('state json still reports percent + total_phases when the milestone IS bounded (versioned ROADMAP)', () => {
|
|
||||||
// Control: a versioned ROADMAP heading matching the asserted milestone
|
|
||||||
// keeps the read path unchanged — the guard only fires when unbounded.
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
fs.writeFileSync(statePath, [
|
|
||||||
'---',
|
|
||||||
'gsd_state_version: 1.0',
|
|
||||||
'milestone: v1.0',
|
|
||||||
'milestone_name: First',
|
|
||||||
'current_phase: "1"',
|
|
||||||
'status: executing',
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'# GSD State',
|
|
||||||
'**Current Phase:** 1',
|
|
||||||
'**Status:** Executing Phase 1',
|
|
||||||
'',
|
|
||||||
].join('\n'));
|
|
||||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), [
|
|
||||||
'# ROADMAP',
|
|
||||||
'## Milestone 1: First Milestone v1.0',
|
|
||||||
'### Phase 1: a',
|
|
||||||
'### Phase 2: b',
|
|
||||||
'',
|
|
||||||
].join('\n'));
|
|
||||||
const dir01 = path.join(tmpDir, '.planning', 'phases', '01');
|
|
||||||
fs.mkdirSync(dir01, { recursive: true });
|
|
||||||
fs.writeFileSync(path.join(dir01, '01-PLAN.md'), '# Plan\n');
|
|
||||||
fs.writeFileSync(path.join(dir01, '01-SUMMARY.md'), '# Summary\n');
|
|
||||||
|
|
||||||
const result = runGsdTools('state json --raw', tmpDir);
|
|
||||||
assert.ok(result.success, `state json failed: ${result.error}`);
|
|
||||||
const out = JSON.parse(result.output);
|
|
||||||
assert.ok(
|
|
||||||
out.progress && typeof out.progress.percent === 'number',
|
|
||||||
`state json must report a numeric percent when the milestone is bounded; got progress=${JSON.stringify(out.progress)}`,
|
|
||||||
);
|
|
||||||
assert.strictEqual(
|
|
||||||
out.progress.total_phases,
|
|
||||||
2,
|
|
||||||
'bounded read path must report the versioned milestone phase count (2)',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
/**
|
|
||||||
* Regression test for bug #1818, updated for #3019.
|
|
||||||
*
|
|
||||||
* Original #1818 invariant: gsd-tools must NOT silently ignore --help/-h
|
|
||||||
* and proceed with a destructive command — that turned AI-agent
|
|
||||||
* hallucinations into accidental data loss (e.g. `phases clear --help`
|
|
||||||
* deleting phase dirs because the flag was dropped).
|
|
||||||
*
|
|
||||||
* #3019 update: the same destructive-protection invariant still holds,
|
|
||||||
* but the response shape changed. Previously --help → non-zero error
|
|
||||||
* exit. Now --help → render top-level usage and exit 0 WITHOUT running
|
|
||||||
* the command. Both shapes satisfy the original invariant ("the
|
|
||||||
* destructive command did not execute"); the new shape also restores
|
|
||||||
* subcommand discoverability for `gsd-sdk query <subcommand> --help`.
|
|
||||||
*
|
|
||||||
* The tests therefore assert two things:
|
|
||||||
* 1. The destructive command did NOT run (anti-hallucination invariant).
|
|
||||||
* 2. The output contains the top-level usage (#3019 discoverability).
|
|
||||||
*
|
|
||||||
* --version remains rejected — it's never a valid gsd-tools flag and has
|
|
||||||
* no discovery use-case.
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
const { runGsdTools, createTempProject, cleanup, isUsageOutput } = require('./helpers.cjs');
|
|
||||||
|
|
||||||
describe('unknown flag guard (bug #1818, updated for #3019)', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = createTempProject();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── --help renders usage and does NOT run the destructive command ────────
|
|
||||||
|
|
||||||
test('phases clear --help renders usage and does NOT clear phase dirs', () => {
|
|
||||||
// Create a sentinel phase dir so we can assert it survives.
|
|
||||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', 'phase-99');
|
|
||||||
fs.mkdirSync(phaseDir, { recursive: true });
|
|
||||||
fs.writeFileSync(path.join(phaseDir, 'PLAN.md'), 'sentinel');
|
|
||||||
|
|
||||||
const result = runGsdTools(['phases', 'clear', '--help'], tmpDir);
|
|
||||||
assert.strictEqual(result.success, true, 'help renders, no error exit');
|
|
||||||
assert.ok(isUsageOutput(result.output), `expected top-level usage, got: ${result.output}`);
|
|
||||||
// Anti-hallucination invariant: the destructive command did NOT run.
|
|
||||||
assert.ok(fs.existsSync(phaseDir), 'phase dir must survive — clear must not have executed');
|
|
||||||
assert.ok(fs.existsSync(path.join(phaseDir, 'PLAN.md')));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('generate-slug hello --help renders usage and does NOT emit a slug', () => {
|
|
||||||
const ok = runGsdTools(['generate-slug', 'hello'], tmpDir);
|
|
||||||
assert.strictEqual(ok.success, true, 'control: generate-slug works without --help');
|
|
||||||
// The control output is just the slug; the help output is the usage.
|
|
||||||
const slugOut = ok.output;
|
|
||||||
assert.ok(slugOut && !isUsageOutput(slugOut), `control should not be usage: ${slugOut}`);
|
|
||||||
|
|
||||||
const result = runGsdTools(['generate-slug', 'hello', '--help'], tmpDir);
|
|
||||||
assert.strictEqual(result.success, true);
|
|
||||||
assert.ok(isUsageOutput(result.output), 'help renders top-level usage');
|
|
||||||
assert.notEqual(result.output, slugOut, 'help output must differ from the slug — generate-slug must not have run');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('phase complete --help renders usage and does NOT mark a phase complete', () => {
|
|
||||||
const result = runGsdTools(['phase', 'complete', '--help'], tmpDir);
|
|
||||||
assert.strictEqual(result.success, true);
|
|
||||||
assert.ok(isUsageOutput(result.output));
|
|
||||||
// success:true + isUsageOutput is sufficient: if the destructive path
|
|
||||||
// had executed it would have emitted a phase-resolution error to stderr
|
|
||||||
// (success:false), not the usage to stdout (success:true).
|
|
||||||
});
|
|
||||||
|
|
||||||
test('state load --help renders usage', () => {
|
|
||||||
const result = runGsdTools(['state', 'load', '--help'], tmpDir);
|
|
||||||
assert.strictEqual(result.success, true);
|
|
||||||
assert.ok(isUsageOutput(result.output));
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── -h shorthand: same shape ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
test('phases clear -h renders usage and does NOT clear phase dirs', () => {
|
|
||||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', 'phase-42');
|
|
||||||
fs.mkdirSync(phaseDir, { recursive: true });
|
|
||||||
const result = runGsdTools(['phases', 'clear', '-h'], tmpDir);
|
|
||||||
assert.strictEqual(result.success, true);
|
|
||||||
assert.ok(isUsageOutput(result.output));
|
|
||||||
assert.ok(fs.existsSync(phaseDir), 'phase dir must survive');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('generate-slug hello -h renders usage', () => {
|
|
||||||
const result = runGsdTools(['generate-slug', 'hello', '-h'], tmpDir);
|
|
||||||
assert.strictEqual(result.success, true);
|
|
||||||
assert.ok(isUsageOutput(result.output));
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── --version is still rejected — no discovery use-case ──────────────────
|
|
||||||
|
|
||||||
test('generate-slug hello --version is rejected', () => {
|
|
||||||
const result = runGsdTools(['generate-slug', 'hello', '--version'], tmpDir);
|
|
||||||
assert.strictEqual(result.success, false);
|
|
||||||
assert.match(result.error, /--version/);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── current-timestamp --help: same as the others ─────────────────────────
|
|
||||||
|
|
||||||
test('current-timestamp --help renders usage', () => {
|
|
||||||
const result = runGsdTools(['current-timestamp', '--help'], tmpDir);
|
|
||||||
assert.strictEqual(result.success, true);
|
|
||||||
assert.ok(isUsageOutput(result.output));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
/**
|
|
||||||
* Regression tests for bug #1826
|
|
||||||
*
|
|
||||||
* `phases clear` must require an explicit --confirm flag before deleting any
|
|
||||||
* phase directories. Without it, any accidental or hallucinated invocation
|
|
||||||
* wipes the entire .planning/phases/ tree with no warning.
|
|
||||||
*
|
|
||||||
* Rules:
|
|
||||||
* - Phase dirs present + no --confirm → non-zero exit, clear error message
|
|
||||||
* - Phase dirs present + --confirm → deletes, exits 0, reports count
|
|
||||||
* - No phase dirs + no --confirm → exits 0, cleared=0 (nothing to guard)
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
|
||||||
|
|
||||||
describe('bug #1826: phases clear --confirm guard', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = createTempProject();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('phases clear without --confirm is rejected when phase dirs exist', () => {
|
|
||||||
const phasesDir = path.join(tmpDir, '.planning', 'phases');
|
|
||||||
fs.mkdirSync(path.join(phasesDir, '01-foundation'), { recursive: true });
|
|
||||||
fs.mkdirSync(path.join(phasesDir, '02-api'), { recursive: true });
|
|
||||||
|
|
||||||
const result = runGsdTools(['phases', 'clear'], tmpDir);
|
|
||||||
|
|
||||||
assert.ok(!result.success, 'should exit non-zero when dirs exist and --confirm absent');
|
|
||||||
assert.ok(
|
|
||||||
result.error.includes('--confirm'),
|
|
||||||
`error message must mention --confirm; got: ${result.error}`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Dirs must be untouched
|
|
||||||
assert.ok(fs.existsSync(path.join(phasesDir, '01-foundation')), 'dirs must not be deleted');
|
|
||||||
assert.ok(fs.existsSync(path.join(phasesDir, '02-api')), 'dirs must not be deleted');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('phases clear --confirm deletes dirs and reports count', () => {
|
|
||||||
const phasesDir = path.join(tmpDir, '.planning', 'phases');
|
|
||||||
fs.mkdirSync(path.join(phasesDir, '01-foundation'), { recursive: true });
|
|
||||||
fs.mkdirSync(path.join(phasesDir, '02-api'), { recursive: true });
|
|
||||||
|
|
||||||
const result = runGsdTools(['phases', 'clear', '--confirm'], tmpDir);
|
|
||||||
|
|
||||||
assert.ok(result.success, `should succeed with --confirm: ${result.error}`);
|
|
||||||
const out = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(out.cleared, 2);
|
|
||||||
assert.ok(!fs.existsSync(path.join(phasesDir, '01-foundation')), 'dirs should be removed');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('phases clear without --confirm succeeds when no phase dirs exist', () => {
|
|
||||||
// .planning/phases/ exists but is empty — nothing to guard
|
|
||||||
const result = runGsdTools(['phases', 'clear'], tmpDir);
|
|
||||||
|
|
||||||
assert.ok(result.success, `should succeed with empty phases dir: ${result.error}`);
|
|
||||||
const out = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(out.cleared, 0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
// allow-test-rule: structural-implementation-guard
|
|
||||||
// Bug #224 is a platform-specific (Node 24 + Windows) flake in `--pick` where
|
|
||||||
// stdout interception can produce non-deterministic failures. We lock the seam
|
|
||||||
// contract structurally until we have a deterministic Windows reproduction
|
|
||||||
// harness in CI.
|
|
||||||
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const { describe, test, before } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const { runGsdTools } = require('./helpers.cjs');
|
|
||||||
|
|
||||||
const GSD_TOOLS_SRC = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
|
||||||
|
|
||||||
describe('bug #224: --pick stdout capture contract', () => {
|
|
||||||
let src;
|
|
||||||
|
|
||||||
before(() => {
|
|
||||||
src = fs.readFileSync(GSD_TOOLS_SRC, 'utf-8');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('--pick output still succeeds for current-timestamp command', () => {
|
|
||||||
const result = runGsdTools(['current-timestamp', '--pick', 'timestamp']);
|
|
||||||
assert.strictEqual(result.success, true, result.error || 'expected command to succeed');
|
|
||||||
assert.match(result.output, /^\d{4}-\d{2}-\d{2}T/, 'expected ISO timestamp output');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('stdout interception for fd=1 returns a byte count (never undefined)', () => {
|
|
||||||
const mainStart = src.indexOf('async function main()');
|
|
||||||
assert.ok(mainStart !== -1, 'main() must exist');
|
|
||||||
const mainSrc = src.slice(mainStart);
|
|
||||||
|
|
||||||
assert.ok(
|
|
||||||
mainSrc.includes('Buffer.byteLength(') || mainSrc.includes('return data.length'),
|
|
||||||
'stdout interception must return written-byte counts for fd=1 captures'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
/**
|
|
||||||
* Regression test for bug #2268
|
|
||||||
*
|
|
||||||
* cmdInitProgress used a sliding-window pattern that set is_next_to_discuss
|
|
||||||
* only on the FIRST undiscussed phase. Multiple independent undiscussed phases
|
|
||||||
* could not be discussed in parallel — the manager only ever recommended one
|
|
||||||
* discuss action at a time.
|
|
||||||
*
|
|
||||||
* Fix: mark ALL undiscussed phases as is_next_to_discuss = true so the user
|
|
||||||
* can pick any of them.
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
|
||||||
|
|
||||||
function writeRoadmap(tmpDir, phases) {
|
|
||||||
const sections = phases.map(p => {
|
|
||||||
let section = `### Phase ${p.number}: ${p.name}\n\n**Goal:** Do the thing\n`;
|
|
||||||
return section;
|
|
||||||
}).join('\n');
|
|
||||||
const checklist = phases.map(p => {
|
|
||||||
const mark = p.complete ? 'x' : ' ';
|
|
||||||
return `- [${mark}] **Phase ${p.number}: ${p.name}**`;
|
|
||||||
}).join('\n');
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
||||||
`# Roadmap\n\n## Progress\n\n${checklist}\n\n${sections}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeState(tmpDir) {
|
|
||||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '---\nstatus: active\n---\n# State\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
describe('bug #2268: parallel discuss — all undiscussed phases marked is_next_to_discuss', () => {
|
|
||||||
beforeEach(() => { tmpDir = createTempProject(); });
|
|
||||||
afterEach(() => { cleanup(tmpDir); });
|
|
||||||
|
|
||||||
test('two undiscussed phases: both marked is_next_to_discuss', () => {
|
|
||||||
writeState(tmpDir);
|
|
||||||
writeRoadmap(tmpDir, [
|
|
||||||
{ number: '1', name: 'Foundation' },
|
|
||||||
{ number: '2', name: 'Cloud Deployment' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const result = runGsdTools('init manager', tmpDir);
|
|
||||||
const output = JSON.parse(result.output);
|
|
||||||
|
|
||||||
assert.strictEqual(output.phases[0].is_next_to_discuss, true, 'phase 1 should be discussable');
|
|
||||||
assert.strictEqual(output.phases[1].is_next_to_discuss, true, 'phase 2 should also be discussable');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('two undiscussed phases: both get discuss recommendations', () => {
|
|
||||||
writeState(tmpDir);
|
|
||||||
writeRoadmap(tmpDir, [
|
|
||||||
{ number: '1', name: 'Foundation' },
|
|
||||||
{ number: '2', name: 'Cloud Deployment' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const result = runGsdTools('init manager', tmpDir);
|
|
||||||
const output = JSON.parse(result.output);
|
|
||||||
|
|
||||||
const discussActions = output.recommended_actions.filter(a => a.action === 'discuss');
|
|
||||||
assert.strictEqual(discussActions.length, 2, 'should recommend discuss for both undiscussed phases');
|
|
||||||
|
|
||||||
const phases = discussActions.map(a => a.phase).sort();
|
|
||||||
assert.deepStrictEqual(phases, ['1', '2']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('five undiscussed phases: all five marked is_next_to_discuss', () => {
|
|
||||||
writeState(tmpDir);
|
|
||||||
writeRoadmap(tmpDir, [
|
|
||||||
{ number: '1', name: 'Alpha' },
|
|
||||||
{ number: '2', name: 'Beta' },
|
|
||||||
{ number: '3', name: 'Gamma' },
|
|
||||||
{ number: '4', name: 'Delta' },
|
|
||||||
{ number: '5', name: 'Epsilon' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const result = runGsdTools('init manager', tmpDir);
|
|
||||||
const output = JSON.parse(result.output);
|
|
||||||
|
|
||||||
for (const phase of output.phases) {
|
|
||||||
assert.strictEqual(phase.is_next_to_discuss, true, `phase ${phase.number} should be discussable`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('discussed phase stays false; undiscussed sibling is true', () => {
|
|
||||||
writeState(tmpDir);
|
|
||||||
writeRoadmap(tmpDir, [
|
|
||||||
{ number: '1', name: 'Foundation' },
|
|
||||||
{ number: '2', name: 'API Layer' },
|
|
||||||
]);
|
|
||||||
// scaffold CONTEXT.md to mark phase 1 as discussed
|
|
||||||
const dir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
|
||||||
fs.writeFileSync(path.join(dir, '01-CONTEXT.md'), '# Context');
|
|
||||||
|
|
||||||
const result = runGsdTools('init manager', tmpDir);
|
|
||||||
const output = JSON.parse(result.output);
|
|
||||||
|
|
||||||
assert.strictEqual(output.phases[0].is_next_to_discuss, false, 'discussed phase must not be is_next_to_discuss');
|
|
||||||
assert.strictEqual(output.phases[1].is_next_to_discuss, true, 'undiscussed sibling must be is_next_to_discuss');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Regression tests for bug #2601
|
|
||||||
*
|
|
||||||
* `config-set-model-profile inherit` (and `config-set model_profile inherit`)
|
|
||||||
* was rejected by the validator even though the runtime accepts 'inherit' as a
|
|
||||||
* valid model_profile value meaning "inherit from parent configuration".
|
|
||||||
*
|
|
||||||
* Root cause: VALID_PROFILES in model-profiles.cjs is derived from
|
|
||||||
* Object.keys(MODEL_PROFILES['gsd-planner']), which does not include 'inherit'.
|
|
||||||
* cmdConfigSetModelProfile() rejects any value not in VALID_PROFILES.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { describe, test } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
|
||||||
|
|
||||||
describe('bug #2601: config-set-model-profile accepts inherit', () => {
|
|
||||||
test('config-set-model-profile inherit succeeds', (t) => {
|
|
||||||
const tmpDir = createTempProject();
|
|
||||||
t.after(() => cleanup(tmpDir));
|
|
||||||
const result = runGsdTools(['config-set-model-profile', 'inherit'], tmpDir);
|
|
||||||
assert.ok(result.success, `should accept inherit: ${result.error}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('config-set model_profile inherit succeeds', (t) => {
|
|
||||||
const tmpDir = createTempProject();
|
|
||||||
t.after(() => cleanup(tmpDir));
|
|
||||||
const result = runGsdTools(['config-set', 'model_profile', 'inherit'], tmpDir);
|
|
||||||
assert.ok(result.success, `config-set model_profile inherit should succeed: ${result.error}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('config-set-model-profile inherit writes inherit to config', (t) => {
|
|
||||||
const tmpDir = createTempProject();
|
|
||||||
t.after(() => cleanup(tmpDir));
|
|
||||||
runGsdTools(['config-set-model-profile', 'inherit'], tmpDir);
|
|
||||||
const getResult = runGsdTools(['config-get', 'model_profile'], tmpDir);
|
|
||||||
assert.ok(getResult.success, `config-get should succeed: ${getResult.error}`);
|
|
||||||
assert.strictEqual(JSON.parse(getResult.output), 'inherit');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('config-set-model-profile still rejects truly invalid profiles', (t) => {
|
|
||||||
const tmpDir = createTempProject();
|
|
||||||
t.after(() => cleanup(tmpDir));
|
|
||||||
const result = runGsdTools(['config-set-model-profile', 'not-a-real-profile'], tmpDir);
|
|
||||||
assert.ok(!result.success, 'should reject invalid profiles');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
/**
|
|
||||||
* GSD Tools Tests — Bug #2630
|
|
||||||
*
|
|
||||||
* Regression guard: `state milestone-switch` resets STATE.md YAML frontmatter
|
|
||||||
* (milestone, milestone_name, status, progress.*) AND the `## Current Position`
|
|
||||||
* body in a single atomic write. Prior to the fix, the `/gsd:new-milestone`
|
|
||||||
* workflow rewrote the body but left the frontmatter pointing at the previous
|
|
||||||
* milestone, so every downstream reader (state.json, getMilestoneInfo, etc.)
|
|
||||||
* reported the stale milestone.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
|
||||||
|
|
||||||
const STALE_STATE = `---
|
|
||||||
gsd_state_version: 1.0
|
|
||||||
milestone: v1.0
|
|
||||||
milestone_name: Foundation
|
|
||||||
status: completed
|
|
||||||
progress:
|
|
||||||
total_phases: 5
|
|
||||||
completed_phases: 5
|
|
||||||
total_plans: 12
|
|
||||||
completed_plans: 12
|
|
||||||
percent: 100
|
|
||||||
---
|
|
||||||
|
|
||||||
# Project State
|
|
||||||
|
|
||||||
## Current Position
|
|
||||||
|
|
||||||
Phase: 5 (Foundation) — COMPLETED
|
|
||||||
Plan: 3 of 3
|
|
||||||
Status: v1.0 milestone complete
|
|
||||||
Last activity: 2026-04-20 -- v1.0 shipped
|
|
||||||
|
|
||||||
## Accumulated Context
|
|
||||||
|
|
||||||
### Decisions
|
|
||||||
|
|
||||||
- [Phase 1]: Use Node 20
|
|
||||||
`;
|
|
||||||
|
|
||||||
describe('state milestone-switch (#2630)', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = createTempProject();
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
|
||||||
STALE_STATE,
|
|
||||||
'utf-8',
|
|
||||||
);
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
||||||
'# Roadmap\n\n## v1.1 Notifications\n\n### Phase 6: Notify\n',
|
|
||||||
'utf-8',
|
|
||||||
);
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(tmpDir, '.planning', 'config.json'),
|
|
||||||
'{}',
|
|
||||||
'utf-8',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('writes new milestone into frontmatter and resets progress + Current Position', () => {
|
|
||||||
const result = runGsdTools(
|
|
||||||
['state', 'milestone-switch', '--milestone', 'v1.1', '--name', 'Notifications'],
|
|
||||||
tmpDir,
|
|
||||||
);
|
|
||||||
assert.equal(result.success, true, result.error || result.output);
|
|
||||||
|
|
||||||
const after = fs.readFileSync(
|
|
||||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
|
||||||
'utf-8',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Frontmatter reflects the NEW milestone — the core of bug #2630.
|
|
||||||
assert.match(after, /^milestone:\s*v1\.1\s*$/m, 'frontmatter milestone not switched');
|
|
||||||
assert.match(
|
|
||||||
after,
|
|
||||||
/^milestone_name:\s*Notifications\s*$/m,
|
|
||||||
'frontmatter milestone_name not switched',
|
|
||||||
);
|
|
||||||
assert.match(after, /^status:\s*planning\s*$/m, 'status not reset to planning');
|
|
||||||
// Progress counters reset to zero.
|
|
||||||
assert.match(after, /^\s*completed_phases:\s*0\s*$/m, 'completed_phases not reset');
|
|
||||||
assert.match(after, /^\s*completed_plans:\s*0\s*$/m, 'completed_plans not reset');
|
|
||||||
assert.match(after, /^\s*percent:\s*0\s*$/m, 'percent not reset');
|
|
||||||
|
|
||||||
// Body Current Position reset to the new-milestone template.
|
|
||||||
assert.match(after, /Status:\s*Defining requirements/, 'body Status not reset');
|
|
||||||
assert.match(
|
|
||||||
after,
|
|
||||||
/Phase:\s*Not started \(defining requirements\)/,
|
|
||||||
'body Phase not reset',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Accumulated Context is preserved.
|
|
||||||
assert.match(after, /\[Phase 1\]:\s*Use Node 20/, 'Accumulated Context lost');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects missing --milestone', () => {
|
|
||||||
const result = runGsdTools(
|
|
||||||
['state', 'milestone-switch', '--name', 'Something'],
|
|
||||||
tmpDir,
|
|
||||||
);
|
|
||||||
// gsd-tools emits JSON with { error: ... } to stdout even on error paths.
|
|
||||||
const combined = (result.output || '') + (result.error || '');
|
|
||||||
assert.match(combined, /milestone required/i);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Regression test for #2659.
|
|
||||||
*
|
|
||||||
* The `audit-open` dispatch case in bin/gsd-tools.cjs previously called bare
|
|
||||||
* `output(...)` on both the --json and text branches. `output` is never in
|
|
||||||
* local scope — the entire core module is imported as `const core`, so every
|
|
||||||
* other case uses `core.output(...)`. The bare calls therefore crashed with
|
|
||||||
* `ReferenceError: output is not defined` the moment `audit-open` ran.
|
|
||||||
*
|
|
||||||
* This test runs both invocations against a minimal temp project and asserts
|
|
||||||
* they exit successfully with non-empty stdout. It fails with the
|
|
||||||
* ReferenceError on any revision that still has the bare `output(...)` calls.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
|
|
||||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
|
||||||
|
|
||||||
describe('audit-open — does not crash with ReferenceError (#2659)', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = createTempProject('gsd-bug-2659-');
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('audit-open (text output) succeeds and produces stdout', () => {
|
|
||||||
const result = runGsdTools('audit-open', tmpDir);
|
|
||||||
assert.ok(
|
|
||||||
result.success,
|
|
||||||
`audit-open must not crash. stderr: ${result.error}`
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
!/ReferenceError.*output is not defined/.test(result.error || ''),
|
|
||||||
`audit-open must not throw ReferenceError. stderr: ${result.error}`
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
result.output && result.output.length > 0,
|
|
||||||
'audit-open must write a non-empty report to stdout'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('audit-open --json succeeds and produces stdout', () => {
|
|
||||||
const result = runGsdTools(['audit-open', '--json'], tmpDir);
|
|
||||||
assert.ok(
|
|
||||||
result.success,
|
|
||||||
`audit-open --json must not crash. stderr: ${result.error}`
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
!/ReferenceError.*output is not defined/.test(result.error || ''),
|
|
||||||
`audit-open --json must not throw ReferenceError. stderr: ${result.error}`
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
result.output && result.output.length > 0,
|
|
||||||
'audit-open --json must write output to stdout'
|
|
||||||
);
|
|
||||||
let parsed;
|
|
||||||
assert.doesNotThrow(
|
|
||||||
() => { parsed = JSON.parse(result.output); },
|
|
||||||
'audit-open --json must emit valid JSON'
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
parsed !== null && typeof parsed === 'object',
|
|
||||||
'audit-open --json must emit a JSON object or array'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
// allow-test-rule: source-text-is-the-product
|
|
||||||
// Reads .md/.json/.yml product files whose deployed text IS what the
|
|
||||||
// runtime loads — testing text content tests the deployed contract.
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Regression tests for bug #2661:
|
|
||||||
* `/gsd-execute-phase N --auto` with parallelization: true, use_worktrees: false
|
|
||||||
* left ROADMAP plan checkboxes unchecked until a manual
|
|
||||||
* `roadmap update-plan-progress` was run.
|
|
||||||
*
|
|
||||||
* Root cause (workflow-level): execute-plan.md `update_roadmap` step was
|
|
||||||
* gated on a worktree-detection branch that incorrectly conflated
|
|
||||||
* "parallel mode" with "worktree mode". When `parallelization: true,
|
|
||||||
* use_worktrees: false` was configured, the step was still gated by the
|
|
||||||
* worktree-only check (which is true: the executing tree IS the main repo,
|
|
||||||
* not a worktree, so the gate happened to fire correctly there) — the
|
|
||||||
* actual reproducer was a different code path. The original PR #2682 fix
|
|
||||||
* made the sync unconditional, which violated the single-writer contract
|
|
||||||
* for shared ROADMAP.md established by #1486 / dcb50396 in worktree mode.
|
|
||||||
*
|
|
||||||
* Minimal fix (this PR): restore the worktree guard and document its
|
|
||||||
* intent explicitly. The `IS_WORKTREE != "true"` branch IS the
|
|
||||||
* `use_worktrees: false` mode: only that mode runs the in-handler sync.
|
|
||||||
* Worktree mode relies on the orchestrator's post-merge sync at
|
|
||||||
* execute-phase.md §5.7 (lines 815-834) — the single writer for shared
|
|
||||||
* tracking files.
|
|
||||||
*
|
|
||||||
* These tests:
|
|
||||||
* (1) assert the workflow gates the sync call on `use_worktrees: false`
|
|
||||||
* (i.e. the IS_WORKTREE != "true" branch is present and gates the call);
|
|
||||||
* (2) assert the handler itself behaves correctly under the
|
|
||||||
* use_worktrees: false reproducer (the original #2661 case);
|
|
||||||
* (3) assert the handler is idempotent and lock-safe (lockfile is the
|
|
||||||
* in-handler defense; the workflow gate is the cross-handler one).
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
|
||||||
|
|
||||||
const WORKFLOW_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'execute-plan.md');
|
|
||||||
|
|
||||||
function writeRoadmap(tmpDir, content) {
|
|
||||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), content);
|
|
||||||
}
|
|
||||||
|
|
||||||
function readRoadmap(tmpDir) {
|
|
||||||
return fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
|
||||||
}
|
|
||||||
|
|
||||||
function seedPhase(tmpDir, phaseNum, planIds, summaryIds) {
|
|
||||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', `${String(phaseNum).padStart(2, '0')}-test`);
|
|
||||||
fs.mkdirSync(phaseDir, { recursive: true });
|
|
||||||
for (const id of planIds) {
|
|
||||||
fs.writeFileSync(path.join(phaseDir, `${id}-PLAN.md`), `# Plan ${id}`);
|
|
||||||
}
|
|
||||||
for (const id of summaryIds) {
|
|
||||||
fs.writeFileSync(path.join(phaseDir, `${id}-SUMMARY.md`), `# Summary ${id}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const THREE_PLAN_ROADMAP = `# Roadmap
|
|
||||||
|
|
||||||
- [ ] Phase 1: Test phase with three parallel plans
|
|
||||||
- [ ] 01-01-PLAN.md
|
|
||||||
- [ ] 01-02-PLAN.md
|
|
||||||
- [ ] 01-03-PLAN.md
|
|
||||||
|
|
||||||
### Phase 1: Test
|
|
||||||
**Goal:** Parallel execution regression test
|
|
||||||
**Plans:** 3 plans
|
|
||||||
|
|
||||||
## Progress
|
|
||||||
|
|
||||||
| Phase | Milestone | Plans Complete | Status | Completed |
|
|
||||||
|-------|-----------|----------------|--------|-----------|
|
|
||||||
| 1. Test | v1.0 | 0/3 | Planned | |
|
|
||||||
`;
|
|
||||||
|
|
||||||
// ─── Structural: workflow gates sync on use_worktrees=false ──────────────────
|
|
||||||
|
|
||||||
describe('bug #2661: execute-plan.md update_roadmap gating', () => {
|
|
||||||
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
|
||||||
const stepMatch = content.match(
|
|
||||||
/<step name="update_roadmap">([\s\S]*?)<\/step>/
|
|
||||||
);
|
|
||||||
const step = stepMatch && stepMatch[1];
|
|
||||||
|
|
||||||
test('update_roadmap step exists and invokes roadmap.update-plan-progress', () => {
|
|
||||||
assert.ok(stepMatch, 'update_roadmap step must exist');
|
|
||||||
// After #3797 architectural fix, callsites use gsd_run
|
|
||||||
assert.ok(
|
|
||||||
/gsd_run query roadmap\.update-plan-progress/.test(step),
|
|
||||||
'update_roadmap must still invoke roadmap.update-plan-progress'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('use_worktrees: false mode — sync call is gated to fire (the #2661 reproducer)', () => {
|
|
||||||
// The non-worktree branch must contain the sync call.
|
|
||||||
// After #3797 architectural fix, callsites use gsd_run
|
|
||||||
assert.ok(
|
|
||||||
/IS_WORKTREE.*!=.*"true"[\s\S]*?gsd_run query roadmap\.update-plan-progress/.test(step),
|
|
||||||
'sync call must execute on the IS_WORKTREE != "true" branch (use_worktrees: false)'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('use_worktrees: true mode — sync call does NOT fire (single-writer contract)', () => {
|
|
||||||
// The sync call must be inside an `if [ "$IS_WORKTREE" != "true" ]` block,
|
|
||||||
// i.e. it must NOT be unconditional and it must NOT appear on the worktree branch.
|
|
||||||
// We verify by extracting the bash block and checking the call sits under the gate.
|
|
||||||
const bashMatch = step.match(/```bash\s*([\s\S]*?)```/);
|
|
||||||
assert.ok(bashMatch, 'update_roadmap must contain a bash block');
|
|
||||||
const bash = bashMatch[1];
|
|
||||||
|
|
||||||
assert.ok(
|
|
||||||
/IS_WORKTREE/.test(bash),
|
|
||||||
'bash block must include the IS_WORKTREE worktree-detection check'
|
|
||||||
);
|
|
||||||
// Sync call must appear after the guard check, not before.
|
|
||||||
// After #3797 architectural fix, callsites use gsd_run
|
|
||||||
const guardIdx = bash.search(/if \[ "\$IS_WORKTREE" != "true" \]/);
|
|
||||||
const callIdx = bash.search(/gsd_run query roadmap\.update-plan-progress/);
|
|
||||||
assert.ok(guardIdx >= 0, 'guard must be present');
|
|
||||||
assert.ok(callIdx > guardIdx,
|
|
||||||
'sync call must appear inside the use_worktrees: false guard, not before/outside it');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('intent doc references single-writer contract / orchestrator-owns-write', () => {
|
|
||||||
// The prose must justify why worktree mode is excluded so future readers
|
|
||||||
// do not regress this back to unconditional.
|
|
||||||
assert.ok(
|
|
||||||
/worktree|orchestrator|single-writer|#1486|#2661/i.test(step),
|
|
||||||
'update_roadmap must document the contract that justifies the gate'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Handler-level: idempotence + multi-plan sync (use_worktrees: false case) ─
|
|
||||||
|
|
||||||
describe('bug #2661: roadmap update-plan-progress handler (use_worktrees: false)', () => {
|
|
||||||
let tmpDir;
|
|
||||||
beforeEach(() => { tmpDir = createTempProject('gsd-2661-'); });
|
|
||||||
afterEach(() => { cleanup(tmpDir); });
|
|
||||||
|
|
||||||
test('three parallel SUMMARY.md files produce three [x] plan checkboxes', () => {
|
|
||||||
writeRoadmap(tmpDir, THREE_PLAN_ROADMAP);
|
|
||||||
seedPhase(tmpDir, 1, ['01-01', '01-02', '01-03'], ['01-01', '01-02', '01-03']);
|
|
||||||
|
|
||||||
const result = runGsdTools('roadmap update-plan-progress 1', tmpDir);
|
|
||||||
assert.ok(result.success, `handler failed: ${result.error}`);
|
|
||||||
|
|
||||||
const roadmap = readRoadmap(tmpDir);
|
|
||||||
assert.ok(roadmap.includes('[x] 01-01-PLAN.md'), 'plan 01-01 should be checked');
|
|
||||||
assert.ok(roadmap.includes('[x] 01-02-PLAN.md'), 'plan 01-02 should be checked');
|
|
||||||
assert.ok(roadmap.includes('[x] 01-03-PLAN.md'), 'plan 01-03 should be checked');
|
|
||||||
assert.ok(roadmap.includes('3/3'), 'progress row should reflect 3/3');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handler is idempotent — second call produces identical content', () => {
|
|
||||||
writeRoadmap(tmpDir, THREE_PLAN_ROADMAP);
|
|
||||||
seedPhase(tmpDir, 1, ['01-01', '01-02', '01-03'], ['01-01', '01-02', '01-03']);
|
|
||||||
|
|
||||||
const first = runGsdTools('roadmap update-plan-progress 1', tmpDir);
|
|
||||||
assert.ok(first.success, first.error);
|
|
||||||
const afterFirst = readRoadmap(tmpDir);
|
|
||||||
|
|
||||||
const second = runGsdTools('roadmap update-plan-progress 1', tmpDir);
|
|
||||||
assert.ok(second.success, second.error);
|
|
||||||
const afterSecond = readRoadmap(tmpDir);
|
|
||||||
|
|
||||||
assert.strictEqual(afterSecond, afterFirst,
|
|
||||||
'repeated invocation must not mutate ROADMAP.md further (idempotent)');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('partial completion: only plans with SUMMARY.md get [x]', () => {
|
|
||||||
writeRoadmap(tmpDir, THREE_PLAN_ROADMAP);
|
|
||||||
// Only plan 01-02 has a SUMMARY.md
|
|
||||||
seedPhase(tmpDir, 1, ['01-01', '01-02', '01-03'], ['01-02']);
|
|
||||||
|
|
||||||
const result = runGsdTools('roadmap update-plan-progress 1', tmpDir);
|
|
||||||
assert.ok(result.success, result.error);
|
|
||||||
|
|
||||||
const roadmap = readRoadmap(tmpDir);
|
|
||||||
assert.ok(roadmap.includes('[ ] 01-01-PLAN.md'), 'plan 01-01 should remain unchecked');
|
|
||||||
assert.ok(roadmap.includes('[x] 01-02-PLAN.md'), 'plan 01-02 should be checked');
|
|
||||||
assert.ok(roadmap.includes('[ ] 01-03-PLAN.md'), 'plan 01-03 should remain unchecked');
|
|
||||||
assert.ok(roadmap.includes('1/3'), 'progress row should reflect 1/3');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('lockfile contention: concurrent handler invocations within a single tree do not corrupt ROADMAP.md', async () => {
|
|
||||||
// Scope: lockfile only serializes within a single working tree. Cross-worktree
|
|
||||||
// serialization is enforced by the workflow gate (worktree mode never calls
|
|
||||||
// this handler from execute-plan.md), not by the lockfile.
|
|
||||||
writeRoadmap(tmpDir, THREE_PLAN_ROADMAP);
|
|
||||||
seedPhase(tmpDir, 1, ['01-01', '01-02', '01-03'], ['01-01', '01-02', '01-03']);
|
|
||||||
|
|
||||||
const invocations = Array.from({ length: 3 }, () =>
|
|
||||||
new Promise((resolve) => {
|
|
||||||
const r = runGsdTools('roadmap update-plan-progress 1', tmpDir);
|
|
||||||
resolve(r);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const results = await Promise.all(invocations);
|
|
||||||
|
|
||||||
for (const r of results) {
|
|
||||||
assert.ok(r.success, `concurrent handler invocation failed: ${r.error}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const roadmap = readRoadmap(tmpDir);
|
|
||||||
// Structural integrity: each checkbox appears exactly once, progress row intact.
|
|
||||||
for (const id of ['01-01', '01-02', '01-03']) {
|
|
||||||
const occurrences = roadmap.split(`[x] ${id}-PLAN.md`).length - 1;
|
|
||||||
assert.strictEqual(occurrences, 1,
|
|
||||||
`plan ${id} checkbox should appear exactly once (got ${occurrences})`);
|
|
||||||
}
|
|
||||||
assert.ok(roadmap.includes('3/3'), 'progress row should reflect 3/3 after concurrent runs');
|
|
||||||
// Lockfile should have been cleaned up after the final release.
|
|
||||||
assert.ok(
|
|
||||||
!fs.existsSync(path.join(tmpDir, '.planning', 'ROADMAP.md.lock')),
|
|
||||||
'ROADMAP.md.lock should be released after concurrent invocations settle'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
/**
|
|
||||||
* Regression tests for issue #2769
|
|
||||||
*
|
|
||||||
* The Requirements header in ROADMAP.md phase blocks renders identically in
|
|
||||||
* markdown for three textually distinct forms:
|
|
||||||
*
|
|
||||||
* **Requirements:** colon INSIDE bold delimiters
|
|
||||||
* **Requirements**: colon OUTSIDE bold delimiters
|
|
||||||
* **Requirements** : space-then-colon outside bold
|
|
||||||
*
|
|
||||||
* Two parsers in the codebase used opposing strict regexes — one only
|
|
||||||
* matched the outside-colon form (init.cjs / init.ts), the other only the
|
|
||||||
* inside-colon form (phase.cjs `cmdPhaseComplete` REQUIREMENTS.md
|
|
||||||
* traceability sweep). Both must accept all three variants so phase
|
|
||||||
* metadata propagation is robust to authoring style.
|
|
||||||
*
|
|
||||||
* Tests for the init query side live in `tests/init.test.cjs` (parameterized
|
|
||||||
* over the three variants). This file exercises the inverse bug in
|
|
||||||
* `phase complete`: the REQUIREMENTS.md checkbox must flip when ROADMAP
|
|
||||||
* uses the outside-colon form, which previously was silently skipped.
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
|
||||||
|
|
||||||
describe('bug #2769: phase complete ticks REQUIREMENTS.md across header variants', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = createTempProject();
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
|
||||||
['---', 'current_phase: 1', 'status: executing', '---', '# State', ''].join('\n'),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
const headerVariants = [
|
|
||||||
{ name: 'colon inside bold (**Requirements:**)', header: '**Requirements:** REQ-001' },
|
|
||||||
{ name: 'colon outside bold (**Requirements**:)', header: '**Requirements**: REQ-001' },
|
|
||||||
{ name: 'space before colon (**Requirements** :)', header: '**Requirements** : REQ-001' },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const variant of headerVariants) {
|
|
||||||
test(`flips REQ-001 checkbox in REQUIREMENTS.md when ROADMAP uses ${variant.name}`, () => {
|
|
||||||
const phasesDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
|
||||||
fs.mkdirSync(phasesDir, { recursive: true });
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(phasesDir, '01-1-PLAN.md'),
|
|
||||||
['---', 'phase: 1', 'plan: 1', '---', '# Plan 1', ''].join('\n'),
|
|
||||||
);
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(phasesDir, '01-1-SUMMARY.md'),
|
|
||||||
['---', 'status: complete', '---', '# Summary', 'Done.'].join('\n'),
|
|
||||||
);
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(phasesDir, '01-VERIFICATION.md'),
|
|
||||||
['---', 'status: passed', 'score: "1/1"', '---', '# Verification', 'Passed.'].join('\n'),
|
|
||||||
);
|
|
||||||
|
|
||||||
const roadmap = [
|
|
||||||
'# Roadmap',
|
|
||||||
'',
|
|
||||||
'### Phase 1: Foundation',
|
|
||||||
'',
|
|
||||||
'**Goal:** Build core',
|
|
||||||
variant.header,
|
|
||||||
'**Plans:** 1 plans',
|
|
||||||
'',
|
|
||||||
'Plans:',
|
|
||||||
'- [x] 01-1-PLAN.md',
|
|
||||||
'',
|
|
||||||
'| Phase | Plans | Status | Completed |',
|
|
||||||
'|-------|-------|--------|-----------|',
|
|
||||||
'| 1. Foundation | 0/1 | Pending | - |',
|
|
||||||
].join('\n');
|
|
||||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
|
|
||||||
|
|
||||||
const requirements = [
|
|
||||||
'# Requirements',
|
|
||||||
'',
|
|
||||||
'## Functional Requirements',
|
|
||||||
'',
|
|
||||||
'- [ ] **REQ-001**: Core data model',
|
|
||||||
'',
|
|
||||||
'## Traceability',
|
|
||||||
'',
|
|
||||||
'| REQ-ID | Phase | Status |',
|
|
||||||
'|--------|-------|--------|',
|
|
||||||
'| REQ-001 | 1 | Pending |',
|
|
||||||
].join('\n');
|
|
||||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), requirements);
|
|
||||||
|
|
||||||
const result = runGsdTools(['phase', 'complete', '1'], tmpDir);
|
|
||||||
assert.ok(result.success, `phase complete failed: ${result.error}`);
|
|
||||||
|
|
||||||
const updated = fs.readFileSync(
|
|
||||||
path.join(tmpDir, '.planning', 'REQUIREMENTS.md'),
|
|
||||||
'utf-8',
|
|
||||||
);
|
|
||||||
assert.match(
|
|
||||||
updated,
|
|
||||||
/-\s*\[x\]\s*\*\*REQ-001\*\*/,
|
|
||||||
`REQ-001 checkbox must be flipped to [x] when ROADMAP header is "${variant.header}". Got:\n${updated}`,
|
|
||||||
);
|
|
||||||
assert.match(
|
|
||||||
updated,
|
|
||||||
/\|\s*REQ-001\s*\|\s*1\s*\|\s*Complete\s*\|/,
|
|
||||||
`Traceability row for REQ-001 must be marked Complete. Got:\n${updated}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Regression test for #2911.
|
|
||||||
*
|
|
||||||
* Two bugs in the `audit-open` dispatch case in bin/gsd-tools.cjs:
|
|
||||||
*
|
|
||||||
* 1. Bare `output(...)` calls (only `core.output` is in scope) → ReferenceError.
|
|
||||||
* 2. Even after switching to `core.output(formatted, raw)`, the human-readable
|
|
||||||
* branch JSON-stringifies the formatted string because `core.output` only
|
|
||||||
* bypasses JSON encoding when called as `core.output(null, true, rawValue)`.
|
|
||||||
* Result: stdout contains `"━━━…\n Milestone Close: …\n…"` (a JSON string
|
|
||||||
* literal) instead of the rendered report.
|
|
||||||
*
|
|
||||||
* The shape assertions below catch both regressions structurally — never via
|
|
||||||
* substring matching on serialized output:
|
|
||||||
*
|
|
||||||
* - text mode: parse stdout as a sequence of lines and assert the expected
|
|
||||||
* section headers exist as standalone lines (i.e. raw text, not escaped).
|
|
||||||
* If the report is JSON-stringified, the stdout is a single line wrapped
|
|
||||||
* in double quotes with `\n` escapes — line-array assertions fail.
|
|
||||||
* - --json mode: JSON.parse the stdout and assert the keys returned by
|
|
||||||
* `auditOpenArtifacts(cwd)` (scanned_at, has_open_items, counts, items)
|
|
||||||
* are present and well-typed.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
|
|
||||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
|
||||||
|
|
||||||
describe('audit-open — output shape (#2911)', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = createTempProject('gsd-bug-2911-');
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('text mode emits the formatted report as raw text (not JSON-encoded)', () => {
|
|
||||||
const result = runGsdTools('audit-open', tmpDir);
|
|
||||||
assert.ok(
|
|
||||||
result.success,
|
|
||||||
`audit-open must not crash. stderr: ${result.error}`
|
|
||||||
);
|
|
||||||
|
|
||||||
const lines = result.output.split('\n').map(l => l.trim()).filter(Boolean);
|
|
||||||
|
|
||||||
// The first non-empty line must be the divider character row, *not* a
|
|
||||||
// JSON-encoded string starting with a quote. If core.output JSON-stringified
|
|
||||||
// the formatted report, the entire payload sits on one line wrapped in
|
|
||||||
// double quotes ("━━━…\n…").
|
|
||||||
assert.ok(
|
|
||||||
!result.output.startsWith('"'),
|
|
||||||
'text-mode stdout must not begin with a JSON quote (would mean the report was JSON.stringified)'
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
!result.output.includes('\\n'),
|
|
||||||
'text-mode stdout must not contain literal "\\n" sequences (would mean the report was JSON.stringified)'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Section headers from formatAuditReport that must appear as standalone lines.
|
|
||||||
assert.ok(
|
|
||||||
lines.includes('Milestone Close: Open Artifact Audit'),
|
|
||||||
`expected report title as a standalone line; got lines: ${JSON.stringify(lines.slice(0, 5))}`
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
lines.includes('All artifact types clear. Safe to proceed.'),
|
|
||||||
`expected the empty-state line as standalone text; got lines: ${JSON.stringify(lines)}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('--json mode emits parseable JSON matching auditOpenArtifacts shape', () => {
|
|
||||||
const result = runGsdTools(['audit-open', '--json'], tmpDir);
|
|
||||||
assert.ok(
|
|
||||||
result.success,
|
|
||||||
`audit-open --json must not crash. stderr: ${result.error}`
|
|
||||||
);
|
|
||||||
|
|
||||||
let parsed;
|
|
||||||
assert.doesNotThrow(
|
|
||||||
() => { parsed = JSON.parse(result.output); },
|
|
||||||
'audit-open --json must emit valid JSON (not a doubly-stringified string)'
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(typeof parsed, 'object', 'parsed payload must be an object');
|
|
||||||
assert.ok(parsed !== null, 'parsed payload must not be null');
|
|
||||||
|
|
||||||
// Shape contract from auditOpenArtifacts() in gsd-core/bin/lib/audit.cjs.
|
|
||||||
assert.equal(typeof parsed.scanned_at, 'string', 'must include scanned_at ISO timestamp');
|
|
||||||
assert.equal(typeof parsed.has_open_items, 'boolean', 'must include has_open_items boolean');
|
|
||||||
assert.equal(typeof parsed.counts, 'object', 'must include counts object');
|
|
||||||
assert.equal(typeof parsed.items, 'object', 'must include items object');
|
|
||||||
|
|
||||||
const expectedCountKeys = [
|
|
||||||
'debug_sessions', 'quick_tasks', 'threads', 'todos',
|
|
||||||
'seeds', 'uat_gaps', 'verification_gaps', 'context_questions', 'total',
|
|
||||||
];
|
|
||||||
for (const key of expectedCountKeys) {
|
|
||||||
assert.equal(
|
|
||||||
typeof parsed.counts[key], 'number',
|
|
||||||
`counts.${key} must be a number`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const expectedItemKeys = [
|
|
||||||
'debug_sessions', 'quick_tasks', 'threads', 'todos',
|
|
||||||
'seeds', 'uat_gaps', 'verification_gaps', 'context_questions',
|
|
||||||
];
|
|
||||||
for (const key of expectedItemKeys) {
|
|
||||||
assert.ok(
|
|
||||||
Array.isArray(parsed.items[key]),
|
|
||||||
`items.${key} must be an array`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
/**
|
|
||||||
* GSD Tools Tests — detect-custom-files misses skills/ directory (#2942)
|
|
||||||
*
|
|
||||||
* After v1.39.0 skill consolidation (#2790), skills/ became a GSD-managed root.
|
|
||||||
* GSD_MANAGED_DIRS was missing 'skills', so user-added GSD-prefixed skill
|
|
||||||
* directories like skills/gsd-custom-skill/SKILL.md were never walked and got
|
|
||||||
* silently destroyed during /gsd-update.
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const crypto = require('crypto');
|
|
||||||
const { runGsdTools, createTempDir, cleanup } = require('./helpers.cjs');
|
|
||||||
|
|
||||||
function sha256(content) {
|
|
||||||
return crypto.createHash('sha256').update(content).digest('hex');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Write a fake gsd-file-manifest.json into configDir with the given file entries.
|
|
||||||
* Each entry is also written to disk so the directory structure exists.
|
|
||||||
*/
|
|
||||||
function writeManifest(configDir, files) {
|
|
||||||
const manifest = {
|
|
||||||
version: '1.39.0',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
files: {}
|
|
||||||
};
|
|
||||||
for (const [relPath, content] of Object.entries(files)) {
|
|
||||||
const fullPath = path.join(configDir, relPath);
|
|
||||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
||||||
fs.writeFileSync(fullPath, content);
|
|
||||||
manifest.files[relPath] = sha256(content);
|
|
||||||
}
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(configDir, 'gsd-file-manifest.json'),
|
|
||||||
JSON.stringify(manifest, null, 2)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Write a file inside configDir (creating parent dirs), but do NOT add it to the manifest.
|
|
||||||
*/
|
|
||||||
function writeCustomFile(configDir, relPath, content) {
|
|
||||||
const fullPath = path.join(configDir, relPath);
|
|
||||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
||||||
fs.writeFileSync(fullPath, content);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('detect-custom-files — skills/ directory missing from GSD_MANAGED_DIRS (#2942)', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = createTempDir('gsd-2942-skills-');
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test 1: detects custom GSD-prefixed skill in skills/gsd-<name>/SKILL.md
|
|
||||||
test('detects custom skill file at skills/gsd-<name>/SKILL.md', () => {
|
|
||||||
writeManifest(tmpDir, {
|
|
||||||
'skills/gsd-planner/SKILL.md': '# GSD Planner Skill\n',
|
|
||||||
});
|
|
||||||
|
|
||||||
// User-added custom GSD-prefixed skill — NOT in manifest
|
|
||||||
writeCustomFile(tmpDir, 'skills/gsd-test-custom/SKILL.md', '# My Custom Skill\n');
|
|
||||||
|
|
||||||
const result = runGsdTools(
|
|
||||||
['detect-custom-files', '--config-dir', tmpDir],
|
|
||||||
tmpDir
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
||||||
|
|
||||||
const json = JSON.parse(result.output);
|
|
||||||
assert.ok(Array.isArray(json.custom_files), 'custom_files should be an array');
|
|
||||||
assert.ok(json.custom_count >= 1, `custom_count should be >= 1, got ${json.custom_count}`);
|
|
||||||
assert.ok(
|
|
||||||
json.custom_files.includes('skills/gsd-test-custom/SKILL.md'),
|
|
||||||
`skills/gsd-test-custom/SKILL.md should be in custom_files; got: ${JSON.stringify(json.custom_files)}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('does not detect non-gsd shared skills preserved by installer (#1325)', () => {
|
|
||||||
writeManifest(tmpDir, {
|
|
||||||
'skills/gsd-planner/SKILL.md': '# GSD Planner Skill\n',
|
|
||||||
});
|
|
||||||
|
|
||||||
writeCustomFile(tmpDir, 'skills/test-custom/SKILL.md', '# My Custom Skill\n');
|
|
||||||
|
|
||||||
const result = runGsdTools(
|
|
||||||
['detect-custom-files', '--config-dir', tmpDir],
|
|
||||||
tmpDir
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
||||||
|
|
||||||
const json = JSON.parse(result.output);
|
|
||||||
assert.ok(Array.isArray(json.custom_files), 'custom_files should be an array');
|
|
||||||
assert.ok(
|
|
||||||
!json.custom_files.includes('skills/test-custom/SKILL.md'),
|
|
||||||
`non-gsd shared skill should not be in custom_files; got: ${JSON.stringify(json.custom_files)}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test 2: does not flag GSD-owned skills as custom (manifest-tracked path NOT in custom_files)
|
|
||||||
test('does not flag GSD-owned skill as custom when it is tracked in manifest', () => {
|
|
||||||
writeManifest(tmpDir, {
|
|
||||||
'skills/gsd-planner/SKILL.md': '# GSD Planner Skill\n',
|
|
||||||
});
|
|
||||||
|
|
||||||
// No extra files — only the manifest-tracked skill exists
|
|
||||||
|
|
||||||
const result = runGsdTools(
|
|
||||||
['detect-custom-files', '--config-dir', tmpDir],
|
|
||||||
tmpDir
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
||||||
|
|
||||||
const json = JSON.parse(result.output);
|
|
||||||
assert.ok(Array.isArray(json.custom_files), 'custom_files should be an array');
|
|
||||||
assert.ok(
|
|
||||||
!json.custom_files.includes('skills/gsd-planner/SKILL.md'),
|
|
||||||
`GSD-owned skill should NOT be in custom_files; got: ${JSON.stringify(json.custom_files)}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test 3: regression guard — still detects custom files in gsd-core/workflows/
|
|
||||||
test('regression: still detects custom files in gsd-core/workflows/', () => {
|
|
||||||
writeManifest(tmpDir, {
|
|
||||||
'gsd-core/workflows/plan-phase.md': '# Plan Phase\n',
|
|
||||||
'skills/gsd-planner/SKILL.md': '# GSD Planner Skill\n',
|
|
||||||
});
|
|
||||||
|
|
||||||
writeCustomFile(tmpDir, 'gsd-core/workflows/custom-workflow.md', '# My Custom Workflow\n');
|
|
||||||
|
|
||||||
const result = runGsdTools(
|
|
||||||
['detect-custom-files', '--config-dir', tmpDir],
|
|
||||||
tmpDir
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
||||||
|
|
||||||
const json = JSON.parse(result.output);
|
|
||||||
assert.ok(
|
|
||||||
json.custom_files.includes('gsd-core/workflows/custom-workflow.md'),
|
|
||||||
`custom workflow should still be detected; got: ${JSON.stringify(json.custom_files)}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test 4: custom_count matches custom_files.length
|
|
||||||
test('custom_count matches custom_files.length when multiple custom gsd-prefixed skills exist', () => {
|
|
||||||
writeManifest(tmpDir, {
|
|
||||||
'skills/gsd-planner/SKILL.md': '# GSD Planner Skill\n',
|
|
||||||
});
|
|
||||||
|
|
||||||
writeCustomFile(tmpDir, 'skills/gsd-test-custom/SKILL.md', '# Custom Skill One\n');
|
|
||||||
writeCustomFile(tmpDir, 'skills/gsd-another-custom/SKILL.md', '# Custom Skill Two\n');
|
|
||||||
|
|
||||||
const result = runGsdTools(
|
|
||||||
['detect-custom-files', '--config-dir', tmpDir],
|
|
||||||
tmpDir
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
||||||
|
|
||||||
const json = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(
|
|
||||||
json.custom_count,
|
|
||||||
json.custom_files.length,
|
|
||||||
`custom_count (${json.custom_count}) should equal custom_files.length (${json.custom_files.length})`
|
|
||||||
);
|
|
||||||
assert.strictEqual(json.custom_count, 2, 'should detect exactly 2 custom skill files');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test 5: manifest_found: true when manifest is present
|
|
||||||
test('manifest_found is true when manifest is present', () => {
|
|
||||||
writeManifest(tmpDir, {
|
|
||||||
'skills/gsd-planner/SKILL.md': '# GSD Planner Skill\n',
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = runGsdTools(
|
|
||||||
['detect-custom-files', '--config-dir', tmpDir],
|
|
||||||
tmpDir
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
||||||
|
|
||||||
const json = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(json.manifest_found, true, 'manifest_found should be true');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
// allow-test-rule: workflow-markdown-is-the-runtime-contract
|
|
||||||
// Justification: complete-milestone.md IS the runtime — the agent reads and
|
|
||||||
// follows it directly. Asserting the <config-check> block is present in the
|
|
||||||
// markdown is the only way to verify the gate is wired. Per CONTEXT.md L611.
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* #3086 — git.create_tag config gate for milestone tagging.
|
|
||||||
*
|
|
||||||
* Tests:
|
|
||||||
* A. Default value: fresh project returns `true` for git.create_tag
|
|
||||||
* B. config-set false → config-get returns false
|
|
||||||
* C. Invalid value (e.g. "maybe") is rejected by schema validator
|
|
||||||
* D. complete-milestone.md workflow contains the <config-check> gate for git.create_tag
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { describe, test } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
|
|
||||||
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
|
|
||||||
|
|
||||||
const WORKFLOW_PATH = path.join(
|
|
||||||
__dirname,
|
|
||||||
'..',
|
|
||||||
'gsd-core',
|
|
||||||
'workflows',
|
|
||||||
'complete-milestone.md',
|
|
||||||
);
|
|
||||||
|
|
||||||
describe('#3086: git.create_tag config key', () => {
|
|
||||||
test('A. fresh project: config-get git.create_tag returns true (default)', (t) => {
|
|
||||||
const tmpDir = createTempProject('gsd-3086-default-');
|
|
||||||
t.after(() => cleanup(tmpDir));
|
|
||||||
|
|
||||||
const result = runGsdTools(['config-get', 'git.create_tag'], tmpDir, { HOME: tmpDir });
|
|
||||||
assert.ok(result.success, `config-get git.create_tag failed:\n${result.error}`);
|
|
||||||
assert.strictEqual(
|
|
||||||
result.output.trim(),
|
|
||||||
'true',
|
|
||||||
`Expected default value 'true', got: '${result.output.trim()}'`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('B. config-set git.create_tag false → config-get returns false', (t) => {
|
|
||||||
const tmpDir = createTempProject('gsd-3086-set-false-');
|
|
||||||
t.after(() => cleanup(tmpDir));
|
|
||||||
|
|
||||||
const setResult = runGsdTools(['config-set', 'git.create_tag', 'false'], tmpDir, {
|
|
||||||
HOME: tmpDir,
|
|
||||||
});
|
|
||||||
assert.ok(setResult.success, `config-set git.create_tag false failed:\n${setResult.error}`);
|
|
||||||
|
|
||||||
const getResult = runGsdTools(['config-get', 'git.create_tag'], tmpDir, { HOME: tmpDir });
|
|
||||||
assert.ok(getResult.success, `config-get after set failed:\n${getResult.error}`);
|
|
||||||
assert.strictEqual(
|
|
||||||
getResult.output.trim(),
|
|
||||||
'false',
|
|
||||||
`Expected 'false' after set, got: '${getResult.output.trim()}'`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('C. config-set git.create_tag with invalid value "maybe" is rejected', (t) => {
|
|
||||||
const tmpDir = createTempProject('gsd-3086-invalid-');
|
|
||||||
t.after(() => cleanup(tmpDir));
|
|
||||||
|
|
||||||
const result = runGsdTools(['config-set', 'git.create_tag', 'maybe'], tmpDir, {
|
|
||||||
HOME: tmpDir,
|
|
||||||
});
|
|
||||||
assert.ok(
|
|
||||||
!result.success,
|
|
||||||
`Expected config-set to fail for invalid value "maybe", but it succeeded`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('D. complete-milestone.md contains <config-check> gate for git.create_tag', () => {
|
|
||||||
const content = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
|
||||||
assert.ok(
|
|
||||||
content.includes('git.create_tag'),
|
|
||||||
'complete-milestone.md must reference git.create_tag in a <config-check> block',
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
content.includes('<config-check>'),
|
|
||||||
'complete-milestone.md must have a <config-check> block in the git_tag step',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const { test, describe } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
|
|
||||||
|
|
||||||
describe('bug #3150: stats.json includes contiguous decimal phases when .10 exists', () => {
|
|
||||||
test('stats json preserves 06.7/06.8/06.9 alongside 06.10', () => {
|
|
||||||
const tmpDir = createTempProject('gsd-bug-3150-');
|
|
||||||
try {
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
||||||
`# Roadmap\n\n### Phase 06.6: P06.6\n**Goal:** G\n\n### Phase 06.7: P06.7\n**Goal:** G\n\n### Phase 06.8: P06.8\n**Goal:** G\n\n### Phase 06.9: P06.9\n**Goal:** G\n\n### Phase 06.10: P06.10\n**Goal:** G\n`
|
|
||||||
);
|
|
||||||
|
|
||||||
const dirs = ['06.6-a', '06.7-b', '06.8-c', '06.9-d', '06.10-e'];
|
|
||||||
for (const dirName of dirs) {
|
|
||||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', dirName);
|
|
||||||
fs.mkdirSync(phaseDir, { recursive: true });
|
|
||||||
fs.writeFileSync(path.join(phaseDir, 'PLAN.md'), '# plan\n');
|
|
||||||
fs.writeFileSync(path.join(phaseDir, 'SUMMARY.md'), '# summary\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = runGsdTools('stats json', tmpDir);
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
||||||
|
|
||||||
let output;
|
|
||||||
assert.doesNotThrow(
|
|
||||||
() => {
|
|
||||||
output = JSON.parse(result.output);
|
|
||||||
},
|
|
||||||
`Command output must be valid JSON. Raw output prefix: ${result.output.slice(0, 200)}`
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
output && typeof output === 'object' && !Array.isArray(output),
|
|
||||||
`Expected object output, got: ${typeof output}`
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
Array.isArray(output.phases),
|
|
||||||
`Expected output.phases array. Raw output prefix: ${result.output.slice(0, 200)}`
|
|
||||||
);
|
|
||||||
const phaseNumbers = output.phases.map((p) => p.number);
|
|
||||||
|
|
||||||
assert.deepStrictEqual(
|
|
||||||
phaseNumbers,
|
|
||||||
['06.6', '06.7', '06.8', '06.9', '06.10'],
|
|
||||||
'stats.json must not skip 06.7/06.8/06.9 when 06.10 exists'
|
|
||||||
);
|
|
||||||
assert.equal(output.phases_total, 5);
|
|
||||||
assert.equal(output.total_plans, 5);
|
|
||||||
assert.equal(output.total_summaries, 5);
|
|
||||||
} finally {
|
|
||||||
cleanup(tmpDir);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bug #3163: generate-claude-md should write to AGENTS.md on Codex runtime.
|
|
||||||
*
|
|
||||||
* When config.runtime === 'codex' (or GSD_RUNTIME=codex), the generate-claude-md
|
|
||||||
* handler must resolve the output path to AGENTS.md, not CLAUDE.md.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
|
||||||
|
|
||||||
describe('bug #3163: generate-claude-md uses AGENTS.md for Codex runtime', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = createTempProject();
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(tmpDir, '.planning', 'PROJECT.md'),
|
|
||||||
'# Test Project\n\nA Codex-hosted project.\n',
|
|
||||||
'utf-8'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('writes to AGENTS.md when config.runtime is codex and no --output given', () => {
|
|
||||||
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
|
||||||
fs.writeFileSync(
|
|
||||||
configPath,
|
|
||||||
JSON.stringify({ runtime: 'codex', claude_md_path: './CLAUDE.md' }),
|
|
||||||
'utf-8'
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = runGsdTools('generate-claude-md', tmpDir, { HOME: tmpDir });
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
||||||
|
|
||||||
const parsed = JSON.parse(result.output);
|
|
||||||
const realTmpDir = fs.realpathSync(tmpDir);
|
|
||||||
const expectedAgentsPath = path.join(realTmpDir, 'AGENTS.md');
|
|
||||||
|
|
||||||
// The returned path must be AGENTS.md, not CLAUDE.md
|
|
||||||
assert.strictEqual(parsed.claude_md_path, expectedAgentsPath,
|
|
||||||
`Expected output path to be AGENTS.md but got: ${parsed.claude_md_path}`
|
|
||||||
);
|
|
||||||
// AGENTS.md must exist on disk
|
|
||||||
assert.ok(fs.existsSync(expectedAgentsPath), 'AGENTS.md must exist after generation');
|
|
||||||
// CLAUDE.md must NOT be created
|
|
||||||
assert.ok(!fs.existsSync(path.join(realTmpDir, 'CLAUDE.md')), 'CLAUDE.md must not be created for Codex runtime');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('writes to AGENTS.md when GSD_RUNTIME=codex env var is set (env takes precedence over config)', () => {
|
|
||||||
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
|
||||||
// Config says runtime: claude but env overrides to codex
|
|
||||||
fs.writeFileSync(
|
|
||||||
configPath,
|
|
||||||
JSON.stringify({ runtime: 'claude', claude_md_path: './CLAUDE.md' }),
|
|
||||||
'utf-8'
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = runGsdTools('generate-claude-md', tmpDir, { HOME: tmpDir, GSD_RUNTIME: 'codex' });
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
||||||
|
|
||||||
const parsed = JSON.parse(result.output);
|
|
||||||
const realTmpDir = fs.realpathSync(tmpDir);
|
|
||||||
const expectedAgentsPath = path.join(realTmpDir, 'AGENTS.md');
|
|
||||||
|
|
||||||
assert.strictEqual(parsed.claude_md_path, expectedAgentsPath,
|
|
||||||
`Expected output path to be AGENTS.md but got: ${parsed.claude_md_path}`
|
|
||||||
);
|
|
||||||
assert.ok(fs.existsSync(expectedAgentsPath), 'AGENTS.md must exist after generation');
|
|
||||||
assert.ok(!fs.existsSync(path.join(realTmpDir, 'CLAUDE.md')), 'CLAUDE.md must not be created when GSD_RUNTIME=codex');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('--output flag overrides runtime detection when explicitly provided', () => {
|
|
||||||
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
|
||||||
fs.writeFileSync(
|
|
||||||
configPath,
|
|
||||||
JSON.stringify({ runtime: 'codex', claude_md_path: './CLAUDE.md' }),
|
|
||||||
'utf-8'
|
|
||||||
);
|
|
||||||
|
|
||||||
// When --output is explicitly provided, it must be honoured regardless of runtime
|
|
||||||
const result = runGsdTools(
|
|
||||||
['generate-claude-md', '--output', 'EXPLICIT-OUTPUT.md'],
|
|
||||||
tmpDir,
|
|
||||||
{ HOME: tmpDir }
|
|
||||||
);
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
||||||
|
|
||||||
const parsed = JSON.parse(result.output);
|
|
||||||
const realTmpDir = fs.realpathSync(tmpDir);
|
|
||||||
assert.strictEqual(
|
|
||||||
parsed.claude_md_path,
|
|
||||||
path.join(realTmpDir, 'EXPLICIT-OUTPUT.md'),
|
|
||||||
`Expected explicit --output to be honoured, got: ${parsed.claude_md_path}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('non-codex runtime still writes to CLAUDE.md', () => {
|
|
||||||
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
|
||||||
fs.writeFileSync(
|
|
||||||
configPath,
|
|
||||||
JSON.stringify({ runtime: 'claude', claude_md_path: './CLAUDE.md' }),
|
|
||||||
'utf-8'
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = runGsdTools('generate-claude-md', tmpDir, { HOME: tmpDir });
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
||||||
|
|
||||||
const parsed = JSON.parse(result.output);
|
|
||||||
const realTmpDir = fs.realpathSync(tmpDir);
|
|
||||||
assert.strictEqual(
|
|
||||||
parsed.claude_md_path,
|
|
||||||
path.join(realTmpDir, 'CLAUDE.md'),
|
|
||||||
`Expected CLAUDE.md for claude runtime, got: ${parsed.claude_md_path}`
|
|
||||||
);
|
|
||||||
assert.ok(fs.existsSync(path.join(realTmpDir, 'CLAUDE.md')), 'CLAUDE.md must exist for claude runtime');
|
|
||||||
assert.ok(!fs.existsSync(path.join(realTmpDir, 'AGENTS.md')), 'AGENTS.md must not be created for claude runtime');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Regression test for #3197 — gsd-tools config-set rejects workflow._auto_chain_active.
|
|
||||||
*
|
|
||||||
* Root cause: RUNTIME_STATE_KEYS was added to sdk/src/query/config-schema.ts in #3162
|
|
||||||
* but not to gsd-core/bin/lib/config-schema.cjs, so gsd-tools.cjs users still hit
|
|
||||||
* "Unknown config key" when setting workflow._auto_chain_active.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { describe, test } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
|
|
||||||
|
|
||||||
describe('#3197 — gsd-tools.cjs config-set workflow._auto_chain_active', () => {
|
|
||||||
test('config-set workflow._auto_chain_active true succeeds via gsd-tools.cjs (CJS path)', (t) => {
|
|
||||||
const tmpDir = createTempProject();
|
|
||||||
t.after(() => cleanup(tmpDir));
|
|
||||||
|
|
||||||
const result = runGsdTools(['config-set', 'workflow._auto_chain_active', 'true'], tmpDir);
|
|
||||||
assert.ok(
|
|
||||||
result.success,
|
|
||||||
`config-set workflow._auto_chain_active true should succeed, got:\nstdout: ${result.output}\nstderr: ${result.error}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('config-set workflow._auto_chain_active true writes value to config.json', (t) => {
|
|
||||||
const tmpDir = createTempProject();
|
|
||||||
t.after(() => cleanup(tmpDir));
|
|
||||||
|
|
||||||
runGsdTools(['config-set', 'workflow._auto_chain_active', 'true'], tmpDir);
|
|
||||||
|
|
||||||
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
|
||||||
assert.ok(fs.existsSync(configPath), '.planning/config.json must exist after config-set');
|
|
||||||
|
|
||||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
||||||
assert.ok(
|
|
||||||
config.workflow !== undefined && config.workflow._auto_chain_active === true,
|
|
||||||
`Expected workflow._auto_chain_active: true in config.json, got: ${JSON.stringify(config)}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('config-set workflow._auto_chain_active false writes false to config.json', (t) => {
|
|
||||||
const tmpDir = createTempProject();
|
|
||||||
t.after(() => cleanup(tmpDir));
|
|
||||||
|
|
||||||
runGsdTools(['config-set', 'workflow._auto_chain_active', 'false'], tmpDir);
|
|
||||||
|
|
||||||
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
|
||||||
assert.ok(fs.existsSync(configPath), '.planning/config.json must exist after config-set');
|
|
||||||
|
|
||||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
||||||
assert.ok(
|
|
||||||
config.workflow !== undefined && config.workflow._auto_chain_active === false,
|
|
||||||
`Expected workflow._auto_chain_active: false in config.json, got: ${JSON.stringify(config)}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
/**
|
|
||||||
* Regression tests for bug #3243.
|
|
||||||
*
|
|
||||||
* The CJS dispatcher (gsd-tools.cjs) must accept dotted canonical command
|
|
||||||
* form (e.g. `state.update`) as well as the spaced form (`state update`).
|
|
||||||
* Workflow markdown files emit `gsd-sdk query <domain>.<subcommand>` calls,
|
|
||||||
* and any caller that bypasses the SDK (stale npm binary, direct shell-out,
|
|
||||||
* third-party script) would hit "Unknown command: <domain>.<subcommand>".
|
|
||||||
*
|
|
||||||
* The fix: a top-of-main() shim that splits args[0] on the first `.` when
|
|
||||||
* present and normalizes to the spaced form before the switch is reached.
|
|
||||||
*
|
|
||||||
* This test file uses runGsdTools() — never readFileSync + .includes().
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
|
||||||
|
|
||||||
describe('bug #3243: CJS dispatcher accepts dotted canonical command form', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = createTempProject();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── generate-slug: no project structure needed, deterministic output ────
|
|
||||||
|
|
||||||
test('generate-slug.hello-world (dotted) produces the same slug as spaced form', () => {
|
|
||||||
const spaced = runGsdTools(['generate-slug', 'hello-world'], tmpDir);
|
|
||||||
assert.strictEqual(spaced.success, true, [
|
|
||||||
'control (spaced form) failed:',
|
|
||||||
spaced.error,
|
|
||||||
].join(' '));
|
|
||||||
|
|
||||||
const dotted = runGsdTools(['generate-slug.hello-world'], tmpDir);
|
|
||||||
// Before the fix this errors: "Unknown command: generate-slug.hello-world"
|
|
||||||
assert.strictEqual(dotted.success, true, [
|
|
||||||
'dotted form must not emit "Unknown command":',
|
|
||||||
dotted.error,
|
|
||||||
].join(' '));
|
|
||||||
assert.strictEqual(dotted.output, spaced.output,
|
|
||||||
'dotted form must produce identical output to spaced form');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('current-timestamp.date (dotted) produces the same output as spaced form', () => {
|
|
||||||
const spaced = runGsdTools(['current-timestamp', 'date'], tmpDir);
|
|
||||||
assert.strictEqual(spaced.success, true, [
|
|
||||||
'control (spaced form) failed:',
|
|
||||||
spaced.error,
|
|
||||||
].join(' '));
|
|
||||||
|
|
||||||
const dotted = runGsdTools(['current-timestamp.date'], tmpDir);
|
|
||||||
assert.strictEqual(dotted.success, true, [
|
|
||||||
'dotted form must not emit "Unknown command":',
|
|
||||||
dotted.error,
|
|
||||||
].join(' '));
|
|
||||||
assert.strictEqual(dotted.output, spaced.output,
|
|
||||||
'dotted form must produce identical output to spaced form');
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Commands with subcommands that need a project ────────────────────────
|
|
||||||
|
|
||||||
test('validate.plan (dotted) routes into validate handler, not "Unknown command"', () => {
|
|
||||||
const dotted = runGsdTools(['validate.plan'], tmpDir);
|
|
||||||
// Before the fix: success=false, error contains "Unknown command: validate.plan"
|
|
||||||
// After the fix: success=false is still possible (validate needs a PLAN.md),
|
|
||||||
// but the error must NOT mention "Unknown command".
|
|
||||||
const errText = dotted.error || '';
|
|
||||||
assert.ok(
|
|
||||||
!errText.includes('Unknown command: validate.plan'),
|
|
||||||
[
|
|
||||||
'dotted form must not produce "Unknown command: validate.plan".',
|
|
||||||
'Got error:', errText,
|
|
||||||
].join('\n')
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('roadmap.analyze (dotted) routes into roadmap handler, not "Unknown command"', () => {
|
|
||||||
const dotted = runGsdTools(['roadmap.analyze'], tmpDir);
|
|
||||||
// success=true means it reached the handler (even if handler reports no ROADMAP.md).
|
|
||||||
// success=false means dispatcher rejected it — assert the error is NOT "Unknown command".
|
|
||||||
const errText = dotted.error || '';
|
|
||||||
assert.ok(
|
|
||||||
!errText.includes('Unknown command: roadmap.analyze'),
|
|
||||||
[
|
|
||||||
'dotted form must not produce "Unknown command: roadmap.analyze".',
|
|
||||||
'Got error:', errText,
|
|
||||||
].join('\n')
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('phases.list (dotted) routes into phases handler, not "Unknown command"', () => {
|
|
||||||
const dotted = runGsdTools(['phases.list'], tmpDir);
|
|
||||||
const errText = dotted.error || '';
|
|
||||||
assert.ok(
|
|
||||||
!errText.includes('Unknown command: phases.list'),
|
|
||||||
[
|
|
||||||
'dotted form must not produce "Unknown command: phases.list".',
|
|
||||||
'Got error:', errText,
|
|
||||||
].join('\n')
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Multi-dot commands: split on first dot only ──────────────────────────
|
|
||||||
|
|
||||||
test('check.decision-coverage-plan (multi-dot-safe: first dot splits)', () => {
|
|
||||||
const dotted = runGsdTools(['check.decision-coverage-plan'], tmpDir);
|
|
||||||
// "check" is not a known top-level command currently, so this will still
|
|
||||||
// fail — but the error must NOT say "Unknown command: check.decision-coverage-plan"
|
|
||||||
// (the dotted form); it should say something about "check" (the split result).
|
|
||||||
const errText = dotted.error || '';
|
|
||||||
assert.ok(
|
|
||||||
!errText.includes('Unknown command: check.decision-coverage-plan'),
|
|
||||||
[
|
|
||||||
'multi-dot dotted form must not be passed verbatim to "Unknown command".',
|
|
||||||
'Got error:', errText,
|
|
||||||
].join('\n')
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Edge cases ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test('command without dots is unchanged (existing behaviour preserved)', () => {
|
|
||||||
const result = runGsdTools(['generate-slug', 'no-dots-here'], tmpDir);
|
|
||||||
assert.strictEqual(result.success, true, [
|
|
||||||
'spaced-only invocation must still work:',
|
|
||||||
result.error,
|
|
||||||
].join(' '));
|
|
||||||
assert.ok(result.output.length > 0, 'output must be non-empty');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('leading-dot arg (e.g. .hidden) is not mis-routed by the shim', () => {
|
|
||||||
// A leading dot in args[0] like ".hidden" has head="" (empty) after split,
|
|
||||||
// so the shim must reject it and fall through to the existing "Unknown command"
|
|
||||||
// path (not silently reroute to an empty-string command).
|
|
||||||
const result = runGsdTools(['.hidden'], tmpDir);
|
|
||||||
assert.strictEqual(result.success, false, 'leading-dot arg must not succeed');
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── "Unknown command" error message improvement ──────────────────────────
|
|
||||||
|
|
||||||
test('"Unknown command" error for dotted form suggests spaced equivalent', () => {
|
|
||||||
// A genuinely unknown dotted command (e.g. "foo.bar") should include a
|
|
||||||
// "did you mean" hint pointing at the spaced form "foo bar".
|
|
||||||
const result = runGsdTools(['foo.bar'], tmpDir);
|
|
||||||
assert.strictEqual(result.success, false, '"foo.bar" must fail');
|
|
||||||
assert.ok(
|
|
||||||
result.error.includes('foo bar'),
|
|
||||||
[
|
|
||||||
'error for unknown dotted command should suggest spaced form "foo bar".',
|
|
||||||
'Got:', result.error,
|
|
||||||
].join('\n')
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('multi-dot unknown command suggestion uses first-dot split only (a.b.c → "a b.c")', () => {
|
|
||||||
// The shim splits only on the FIRST dot, so the suggestion must mirror that:
|
|
||||||
// "a.b.c" → head="a", rest="b.c" → suggest "a b.c", NOT "a b c".
|
|
||||||
const result = runGsdTools(['a.b.c'], tmpDir);
|
|
||||||
assert.strictEqual(result.success, false, '"a.b.c" must fail');
|
|
||||||
assert.ok(
|
|
||||||
result.error.includes('a b.c'),
|
|
||||||
[
|
|
||||||
'suggestion for multi-dot unknown command must use first-dot split: "a b.c".',
|
|
||||||
'Got:', result.error,
|
|
||||||
].join('\n')
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
!result.error.includes('a b c'),
|
|
||||||
[
|
|
||||||
'suggestion must NOT replace all dots ("a b c" is wrong — only first dot splits).',
|
|
||||||
'Got:', result.error,
|
|
||||||
].join('\n')
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,568 +0,0 @@
|
|||||||
/**
|
|
||||||
* GSD Tools Tests — Bug #3257
|
|
||||||
*
|
|
||||||
* Regression guard: `buildStateFrontmatter` must count plan/summary files in
|
|
||||||
* the nested `phases/<N>-<slug>/plans/<N>-PLAN-<NN>-<slug>.md` layout (written
|
|
||||||
* by gsd-plan-phase post-#3139). Prior to this fix, the loop did a flat
|
|
||||||
* `readdirSync` on the phase directory and missed every file inside the
|
|
||||||
* `plans/` subdirectory, so `progress.total_plans` and
|
|
||||||
* `progress.completed_plans` were silently under-counted on every state
|
|
||||||
* mutation that flows through `syncStateFrontmatter → buildStateFrontmatter`.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Helpers
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Write a minimal STATE.md that will trigger syncStateFrontmatter on any write.
|
|
||||||
*/
|
|
||||||
function writeStateFile(tmpDir, overrides = {}) {
|
|
||||||
const phase = overrides.phase || '01';
|
|
||||||
const status = overrides.status || 'executing';
|
|
||||||
const content = [
|
|
||||||
'# Project State',
|
|
||||||
'',
|
|
||||||
`**Current Phase:** ${phase}`,
|
|
||||||
`**Status:** ${status}`,
|
|
||||||
'',
|
|
||||||
'## Current Position',
|
|
||||||
'',
|
|
||||||
`Phase: ${phase} — In progress`,
|
|
||||||
'Status: Executing',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content, 'utf-8');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Write a ROADMAP.md listing the given phase numbers so the milestone-scoped
|
|
||||||
* filter includes them (avoids needing a milestone header to count phases).
|
|
||||||
*/
|
|
||||||
function writeRoadmap(tmpDir, phaseNums) {
|
|
||||||
const lines = ['## Roadmap v1.0'];
|
|
||||||
for (const n of phaseNums) {
|
|
||||||
lines.push('', `### Phase ${n}: Phase ${n}`);
|
|
||||||
}
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
||||||
lines.join('\n'),
|
|
||||||
'utf-8'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Nested layout — core bug (#3257)
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('buildStateFrontmatter nested plans/ layout (#3257)', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = createTempProject();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('counts plans and summaries in nested plans/ subdirectory', () => {
|
|
||||||
// Layout: phases/01-init/plans/1-PLAN-01-setup.md etc.
|
|
||||||
// 2 phases × 3 plans each, all completed (3 summaries each).
|
|
||||||
for (let phase = 1; phase <= 2; phase++) {
|
|
||||||
const phaseSlug = `0${phase}-phase-${phase}`;
|
|
||||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', phaseSlug);
|
|
||||||
const plansDir = path.join(phaseDir, 'plans');
|
|
||||||
fs.mkdirSync(plansDir, { recursive: true });
|
|
||||||
|
|
||||||
for (let plan = 1; plan <= 3; plan++) {
|
|
||||||
const planPad = String(plan).padStart(2, '0');
|
|
||||||
// Reporter's format: {N}-PLAN-{NN}-{slug}.md
|
|
||||||
const planFile = `${phase}-PLAN-${planPad}-step${plan}.md`;
|
|
||||||
const summaryFile = `${phase}-SUMMARY-${planPad}-step${plan}.md`;
|
|
||||||
fs.writeFileSync(path.join(plansDir, planFile), '# Plan\n');
|
|
||||||
fs.writeFileSync(path.join(plansDir, summaryFile), '# Summary\n');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
writeRoadmap(tmpDir, [1, 2]);
|
|
||||||
writeStateFile(tmpDir, { phase: '02' });
|
|
||||||
|
|
||||||
const result = runGsdTools('state update "Last Activity" "2026-05-08"', tmpDir);
|
|
||||||
assert.ok(result.success, `state update failed: ${result.error}`);
|
|
||||||
|
|
||||||
const jsonResult = runGsdTools('state json', tmpDir);
|
|
||||||
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
|
|
||||||
|
|
||||||
const progress = JSON.parse(jsonResult.output).progress;
|
|
||||||
assert.strictEqual(Number(progress.total_plans), 6, 'total_plans must count nested plans/ files (2 phases × 3 plans)');
|
|
||||||
assert.strictEqual(Number(progress.completed_plans), 6, 'completed_plans must count nested summary files (2 phases × 3 summaries)');
|
|
||||||
assert.strictEqual(Number(progress.completed_phases), 2, 'completed_phases: both phases have summaries >= plans');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('counts PLAN-NN-slug form (bare PLAN- prefix, no phase prefix)', () => {
|
|
||||||
// roadmap.cjs uses /^PLAN-\d+.*\.md$/i — test that form too.
|
|
||||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-init');
|
|
||||||
const plansDir = path.join(phaseDir, 'plans');
|
|
||||||
fs.mkdirSync(plansDir, { recursive: true });
|
|
||||||
|
|
||||||
fs.writeFileSync(path.join(plansDir, 'PLAN-01-foundation.md'), '# Plan\n');
|
|
||||||
fs.writeFileSync(path.join(plansDir, 'PLAN-02-infra.md'), '# Plan\n');
|
|
||||||
fs.writeFileSync(path.join(plansDir, 'SUMMARY-01-foundation.md'), '# Summary\n');
|
|
||||||
|
|
||||||
writeRoadmap(tmpDir, [1]);
|
|
||||||
writeStateFile(tmpDir, { phase: '01' });
|
|
||||||
|
|
||||||
const result = runGsdTools('state update "Last Activity" "2026-05-08"', tmpDir);
|
|
||||||
assert.ok(result.success, `state update failed: ${result.error}`);
|
|
||||||
|
|
||||||
const jsonResult = runGsdTools('state json', tmpDir);
|
|
||||||
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
|
|
||||||
|
|
||||||
const progress = JSON.parse(jsonResult.output).progress;
|
|
||||||
assert.strictEqual(Number(progress.total_plans), 2, 'bare PLAN-NN-slug.md files must be counted');
|
|
||||||
assert.strictEqual(Number(progress.completed_plans), 1, 'SUMMARY-NN-slug.md files must be counted');
|
|
||||||
// 1 summary < 2 plans → phase NOT completed
|
|
||||||
assert.strictEqual(Number(progress.completed_phases), 0, 'phase not complete when summaries < plans');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('flat-layout repos are unaffected (no plans/ subdirectory)', () => {
|
|
||||||
// Pre-#3139 flat layout: plans live directly in the phase dir.
|
|
||||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-init');
|
|
||||||
fs.mkdirSync(phaseDir, { recursive: true });
|
|
||||||
|
|
||||||
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
|
|
||||||
fs.writeFileSync(path.join(phaseDir, '01-02-PLAN.md'), '# Plan\n');
|
|
||||||
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary\n');
|
|
||||||
fs.writeFileSync(path.join(phaseDir, '01-02-SUMMARY.md'), '# Summary\n');
|
|
||||||
|
|
||||||
writeRoadmap(tmpDir, [1]);
|
|
||||||
writeStateFile(tmpDir, { phase: '01' });
|
|
||||||
|
|
||||||
const result = runGsdTools('state update "Last Activity" "2026-05-08"', tmpDir);
|
|
||||||
assert.ok(result.success, `state update failed: ${result.error}`);
|
|
||||||
|
|
||||||
const jsonResult = runGsdTools('state json', tmpDir);
|
|
||||||
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
|
|
||||||
|
|
||||||
const progress = JSON.parse(jsonResult.output).progress;
|
|
||||||
assert.strictEqual(Number(progress.total_plans), 2, 'flat layout: top-level *-PLAN.md files counted');
|
|
||||||
assert.strictEqual(Number(progress.completed_plans), 2, 'flat layout: top-level *-SUMMARY.md files counted');
|
|
||||||
assert.strictEqual(Number(progress.completed_phases), 1, 'flat layout: phase complete when summaries >= plans');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('no double-count when both top-level and nested plan files coexist', () => {
|
|
||||||
// Edge case: phase has a top-level plan AND a plans/ subdir.
|
|
||||||
// Only the nested files should be counted (or both, depending on logic),
|
|
||||||
// but the critical thing is no file is counted twice.
|
|
||||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-init');
|
|
||||||
const plansDir = path.join(phaseDir, 'plans');
|
|
||||||
fs.mkdirSync(plansDir, { recursive: true });
|
|
||||||
|
|
||||||
// Top-level flat plan
|
|
||||||
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Top-level Plan\n');
|
|
||||||
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Top-level Summary\n');
|
|
||||||
|
|
||||||
// Nested plan
|
|
||||||
fs.writeFileSync(path.join(plansDir, '1-PLAN-02-nested.md'), '# Nested Plan\n');
|
|
||||||
fs.writeFileSync(path.join(plansDir, '1-SUMMARY-02-nested.md'), '# Nested Summary\n');
|
|
||||||
|
|
||||||
writeRoadmap(tmpDir, [1]);
|
|
||||||
writeStateFile(tmpDir, { phase: '01' });
|
|
||||||
|
|
||||||
const result = runGsdTools('state update "Last Activity" "2026-05-08"', tmpDir);
|
|
||||||
assert.ok(result.success, `state update failed: ${result.error}`);
|
|
||||||
|
|
||||||
const jsonResult = runGsdTools('state json', tmpDir);
|
|
||||||
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
|
|
||||||
|
|
||||||
const progress = JSON.parse(jsonResult.output).progress;
|
|
||||||
// 1 top-level + 1 nested = 2 total (not 4 from double-counting)
|
|
||||||
assert.strictEqual(Number(progress.total_plans), 2, 'mixed layout: no double-counting of plan files');
|
|
||||||
assert.strictEqual(Number(progress.completed_plans), 2, 'mixed layout: no double-counting of summary files');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('empty plans/ directory is a no-op (does not break counting)', () => {
|
|
||||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-init');
|
|
||||||
const plansDir = path.join(phaseDir, 'plans');
|
|
||||||
fs.mkdirSync(plansDir, { recursive: true });
|
|
||||||
// plans/ dir exists but is empty
|
|
||||||
|
|
||||||
// One top-level plan
|
|
||||||
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
|
|
||||||
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary\n');
|
|
||||||
|
|
||||||
writeRoadmap(tmpDir, [1]);
|
|
||||||
writeStateFile(tmpDir, { phase: '01' });
|
|
||||||
|
|
||||||
const result = runGsdTools('state update "Last Activity" "2026-05-08"', tmpDir);
|
|
||||||
assert.ok(result.success, `state update failed: ${result.error}`);
|
|
||||||
|
|
||||||
const jsonResult = runGsdTools('state json', tmpDir);
|
|
||||||
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
|
|
||||||
|
|
||||||
const progress = JSON.parse(jsonResult.output).progress;
|
|
||||||
assert.strictEqual(Number(progress.total_plans), 1, 'empty plans/ must not add phantom plan count');
|
|
||||||
assert.strictEqual(Number(progress.completed_plans), 1, 'empty plans/ must not affect summary count');
|
|
||||||
assert.strictEqual(Number(progress.completed_phases), 1, 'phase complete: 1 summary >= 1 plan');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('PLAN-OUTLINE.md files are excluded from nested plan count', () => {
|
|
||||||
// phase.cjs explicitly excludes *-PLAN-OUTLINE.md (not real plans).
|
|
||||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-init');
|
|
||||||
const plansDir = path.join(phaseDir, 'plans');
|
|
||||||
fs.mkdirSync(plansDir, { recursive: true });
|
|
||||||
|
|
||||||
fs.writeFileSync(path.join(plansDir, '1-PLAN-01-work.md'), '# Real Plan\n');
|
|
||||||
// Outline file — should NOT count as a plan
|
|
||||||
fs.writeFileSync(path.join(plansDir, '1-PLAN-OUTLINE.md'), '# Outline\n');
|
|
||||||
|
|
||||||
writeRoadmap(tmpDir, [1]);
|
|
||||||
writeStateFile(tmpDir, { phase: '01' });
|
|
||||||
|
|
||||||
const result = runGsdTools('state update "Last Activity" "2026-05-08"', tmpDir);
|
|
||||||
assert.ok(result.success, `state update failed: ${result.error}`);
|
|
||||||
|
|
||||||
const jsonResult = runGsdTools('state json', tmpDir);
|
|
||||||
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
|
|
||||||
|
|
||||||
const progress = JSON.parse(jsonResult.output).progress;
|
|
||||||
// Only the real plan should count; outline excluded.
|
|
||||||
assert.strictEqual(Number(progress.total_plans), 1, 'PLAN-OUTLINE.md must not count as a plan');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('pre-bounce files are excluded from nested plan count (bare PLAN- prefix)', () => {
|
|
||||||
// CR finding: PLAN_PRE_BOUNCE_RE was /-PLAN.*\.pre-bounce\.md$/i which missed
|
|
||||||
// bare-prefix files like PLAN-01-foo.pre-bounce.md. Fixed to /\.pre-bounce\.md$/i.
|
|
||||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-init');
|
|
||||||
const plansDir = path.join(phaseDir, 'plans');
|
|
||||||
fs.mkdirSync(plansDir, { recursive: true });
|
|
||||||
|
|
||||||
fs.writeFileSync(path.join(plansDir, '1-PLAN-01-work.md'), '# Real Plan\n');
|
|
||||||
// Pre-bounce files — should NOT count as plans
|
|
||||||
fs.writeFileSync(path.join(plansDir, 'PLAN-01-work.pre-bounce.md'), '# Pre-bounce\n');
|
|
||||||
fs.writeFileSync(path.join(plansDir, '1-PLAN-01-work.pre-bounce.md'), '# Pre-bounce\n');
|
|
||||||
|
|
||||||
writeRoadmap(tmpDir, [1]);
|
|
||||||
writeStateFile(tmpDir, { phase: '01' });
|
|
||||||
|
|
||||||
const result = runGsdTools('state update "Last Activity" "2026-05-08"', tmpDir);
|
|
||||||
assert.ok(result.success, `state update failed: ${result.error}`);
|
|
||||||
|
|
||||||
const jsonResult = runGsdTools('state json', tmpDir);
|
|
||||||
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
|
|
||||||
|
|
||||||
const progress = JSON.parse(jsonResult.output).progress;
|
|
||||||
// Only the real plan should count; pre-bounce files excluded.
|
|
||||||
assert.strictEqual(Number(progress.total_plans), 1, 'pre-bounce files must not count as plans');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('reporter scenario: 2 phases × multiple plans, all complete', () => {
|
|
||||||
// Mirrors the reporter's observation: after a state mutation the progress
|
|
||||||
// block should reflect the TRUE on-disk count, not an under-count.
|
|
||||||
// Phase 1: 4 plans, all with summaries.
|
|
||||||
// Phase 2: 3 plans, all with summaries.
|
|
||||||
// Expected: total=7, completed=7, completed_phases=2.
|
|
||||||
const phases = [
|
|
||||||
{ num: 1, plans: 4 },
|
|
||||||
{ num: 2, plans: 3 },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const { num, plans } of phases) {
|
|
||||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', `0${num}-phase-${num}`);
|
|
||||||
const plansDir = path.join(phaseDir, 'plans');
|
|
||||||
fs.mkdirSync(plansDir, { recursive: true });
|
|
||||||
|
|
||||||
for (let p = 1; p <= plans; p++) {
|
|
||||||
const pad = String(p).padStart(2, '0');
|
|
||||||
fs.writeFileSync(path.join(plansDir, `${num}-PLAN-${pad}-task${p}.md`), '# Plan\n');
|
|
||||||
fs.writeFileSync(path.join(plansDir, `${num}-SUMMARY-${pad}-task${p}.md`), '# Summary\n');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
writeRoadmap(tmpDir, [1, 2]);
|
|
||||||
writeStateFile(tmpDir, { phase: '02' });
|
|
||||||
|
|
||||||
const result = runGsdTools('state update "Last Activity" "2026-05-08"', tmpDir);
|
|
||||||
assert.ok(result.success, `state update failed: ${result.error}`);
|
|
||||||
|
|
||||||
const jsonResult = runGsdTools('state json', tmpDir);
|
|
||||||
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
|
|
||||||
|
|
||||||
const progress = JSON.parse(jsonResult.output).progress;
|
|
||||||
assert.strictEqual(Number(progress.total_plans), 7, 'reporter scenario: total_plans must be 7');
|
|
||||||
assert.strictEqual(Number(progress.completed_plans), 7, 'reporter scenario: completed_plans must be 7');
|
|
||||||
assert.strictEqual(Number(progress.completed_phases), 2, 'reporter scenario: both phases complete');
|
|
||||||
assert.strictEqual(Number(progress.percent), 100, 'reporter scenario: 100% when all plans have summaries');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// cmdStateValidate nested plans/ layout (#3257 — CR finding)
|
|
||||||
//
|
|
||||||
// Prior to this fix, cmdStateValidate did a flat readdirSync on the phase dir
|
|
||||||
// and returned diskPlans=0 for nested layouts, causing false drift warnings
|
|
||||||
// when STATE.md correctly said "Total Plans in Phase: 3".
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('cmdStateValidate nested plans/ layout (#3257)', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = createTempProject();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('no false drift warning when STATE.md plan count matches nested disk count', () => {
|
|
||||||
// Phase 01-init: 3 nested plans, 0 summaries (still executing).
|
|
||||||
// STATE.md says "Total Plans in Phase: 3" — after the fix, validate sees
|
|
||||||
// diskPlans=3 and emits no plan_count drift warning.
|
|
||||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-init');
|
|
||||||
const plansDir = path.join(phaseDir, 'plans');
|
|
||||||
fs.mkdirSync(plansDir, { recursive: true });
|
|
||||||
|
|
||||||
for (let p = 1; p <= 3; p++) {
|
|
||||||
const pad = String(p).padStart(2, '0');
|
|
||||||
fs.writeFileSync(path.join(plansDir, `1-PLAN-${pad}-step${p}.md`), '# Plan\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write STATE.md with correct plan count so validate can check for drift.
|
|
||||||
const stateContent = [
|
|
||||||
'# Project State',
|
|
||||||
'',
|
|
||||||
'**Current Phase:** 01',
|
|
||||||
'**Status:** executing',
|
|
||||||
'**Total Plans in Phase:** 3',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateContent, 'utf-8');
|
|
||||||
|
|
||||||
const result = runGsdTools('state validate', tmpDir);
|
|
||||||
assert.ok(result.success, `state validate failed: ${result.error}`);
|
|
||||||
|
|
||||||
const parsed = JSON.parse(result.output);
|
|
||||||
assert.ok(parsed.valid, `state validate should be valid; warnings: ${JSON.stringify(parsed.warnings)}`);
|
|
||||||
assert.deepStrictEqual(parsed.warnings, [], 'no drift warnings for nested-layout phase with correct plan count');
|
|
||||||
assert.ok(!parsed.drift.plan_count, 'no plan_count drift when nested scan matches STATE.md');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('emits drift warning when STATE.md plan count does not match nested disk count', () => {
|
|
||||||
// STATE.md says 5 but only 2 plans exist on disk — validate should catch it.
|
|
||||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-init');
|
|
||||||
const plansDir = path.join(phaseDir, 'plans');
|
|
||||||
fs.mkdirSync(plansDir, { recursive: true });
|
|
||||||
|
|
||||||
for (let p = 1; p <= 2; p++) {
|
|
||||||
const pad = String(p).padStart(2, '0');
|
|
||||||
fs.writeFileSync(path.join(plansDir, `1-PLAN-${pad}-step${p}.md`), '# Plan\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
const stateContent = [
|
|
||||||
'# Project State',
|
|
||||||
'',
|
|
||||||
'**Current Phase:** 01',
|
|
||||||
'**Status:** executing',
|
|
||||||
'**Total Plans in Phase:** 5',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateContent, 'utf-8');
|
|
||||||
|
|
||||||
const result = runGsdTools('state validate', tmpDir);
|
|
||||||
assert.ok(result.success, `state validate failed: ${result.error}`);
|
|
||||||
|
|
||||||
const parsed = JSON.parse(result.output);
|
|
||||||
assert.ok(!parsed.valid, 'state validate should report invalid when plan counts differ');
|
|
||||||
assert.ok(parsed.warnings.length > 0, 'at least one drift warning expected');
|
|
||||||
assert.ok(parsed.drift.plan_count, 'plan_count drift object must be present');
|
|
||||||
assert.strictEqual(parsed.drift.plan_count.disk, 2, 'disk count must reflect nested scan (2 nested plans)');
|
|
||||||
assert.strictEqual(parsed.drift.plan_count.state, 5, 'state count from STATE.md must be 5');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('PLAN-OUTLINE.md excluded from nested count in validate', () => {
|
|
||||||
// Outline files must not inflate diskPlans and cause false "too few" drift.
|
|
||||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-init');
|
|
||||||
const plansDir = path.join(phaseDir, 'plans');
|
|
||||||
fs.mkdirSync(plansDir, { recursive: true });
|
|
||||||
|
|
||||||
fs.writeFileSync(path.join(plansDir, '1-PLAN-01-work.md'), '# Plan\n');
|
|
||||||
fs.writeFileSync(path.join(plansDir, '1-PLAN-OUTLINE.md'), '# Outline\n'); // must not count
|
|
||||||
|
|
||||||
// STATE.md claims 1 plan — correct after exclusion.
|
|
||||||
const stateContent = [
|
|
||||||
'# Project State',
|
|
||||||
'',
|
|
||||||
'**Current Phase:** 01',
|
|
||||||
'**Status:** executing',
|
|
||||||
'**Total Plans in Phase:** 1',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateContent, 'utf-8');
|
|
||||||
|
|
||||||
const result = runGsdTools('state validate', tmpDir);
|
|
||||||
assert.ok(result.success, `state validate failed: ${result.error}`);
|
|
||||||
|
|
||||||
const parsed = JSON.parse(result.output);
|
|
||||||
assert.ok(parsed.valid, `should be valid (outline excluded); warnings: ${JSON.stringify(parsed.warnings)}`);
|
|
||||||
assert.ok(!parsed.drift.plan_count, 'no plan_count drift when outline excluded from nested count');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// cmdStateSync nested plans/ layout (#3257 — CR finding)
|
|
||||||
//
|
|
||||||
// Prior to this fix, cmdStateSync did a flat readdirSync on each phase dir,
|
|
||||||
// returning plans=0 for nested layouts. It would set "Total Plans in Phase"
|
|
||||||
// to 0 even when plans existed inside plans/ — an under-count that corrupts
|
|
||||||
// the STATE.md progress block.
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('cmdStateSync nested plans/ layout (#3257)', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = createTempProject();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('updates Total Plans in Phase from 0 to correct nested count on sync', () => {
|
|
||||||
// Disk: phase 01-init with 3 nested plans, no summaries.
|
|
||||||
// STATE.md says "Total Plans in Phase: 0" (stale / pre-fix value).
|
|
||||||
// After sync, the field must be updated to 3.
|
|
||||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-init');
|
|
||||||
const plansDir = path.join(phaseDir, 'plans');
|
|
||||||
fs.mkdirSync(plansDir, { recursive: true });
|
|
||||||
|
|
||||||
for (let p = 1; p <= 3; p++) {
|
|
||||||
const pad = String(p).padStart(2, '0');
|
|
||||||
fs.writeFileSync(path.join(plansDir, `1-PLAN-${pad}-step${p}.md`), '# Plan\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
const stateContent = [
|
|
||||||
'# Project State',
|
|
||||||
'',
|
|
||||||
'**Current Phase:** 01',
|
|
||||||
'**Status:** executing',
|
|
||||||
'**Total Plans in Phase:** 0',
|
|
||||||
'**Progress:** [░░░░░░░░░░] 0%',
|
|
||||||
'**Last Activity:** 2026-01-01',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateContent, 'utf-8');
|
|
||||||
|
|
||||||
const result = runGsdTools('state sync', tmpDir);
|
|
||||||
assert.ok(result.success, `state sync failed: ${result.error}`);
|
|
||||||
|
|
||||||
const parsed = JSON.parse(result.output);
|
|
||||||
assert.ok(parsed.synced, 'sync must report synced: true');
|
|
||||||
// The "Total Plans in Phase" change must appear in the changes list.
|
|
||||||
const planCountChange = parsed.changes.find(c => c.startsWith('Total Plans in Phase:'));
|
|
||||||
assert.ok(planCountChange, `changes must include Total Plans in Phase update; got: ${JSON.stringify(parsed.changes)}`);
|
|
||||||
assert.ok(planCountChange.includes('-> 3'), `Total Plans in Phase must update to 3; got: "${planCountChange}"`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('sync dry-run reports correct nested plan count without writing', () => {
|
|
||||||
// --verify flag: sync must report what WOULD change but not write STATE.md.
|
|
||||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-init');
|
|
||||||
const plansDir = path.join(phaseDir, 'plans');
|
|
||||||
fs.mkdirSync(plansDir, { recursive: true });
|
|
||||||
|
|
||||||
for (let p = 1; p <= 2; p++) {
|
|
||||||
const pad = String(p).padStart(2, '0');
|
|
||||||
fs.writeFileSync(path.join(plansDir, `PLAN-${pad}-task${p}.md`), '# Plan\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
const stateContent = [
|
|
||||||
'# Project State',
|
|
||||||
'',
|
|
||||||
'**Current Phase:** 01',
|
|
||||||
'**Status:** executing',
|
|
||||||
'**Total Plans in Phase:** 0',
|
|
||||||
'**Progress:** [░░░░░░░░░░] 0%',
|
|
||||||
'**Last Activity:** 2026-01-01',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateContent, 'utf-8');
|
|
||||||
|
|
||||||
const result = runGsdTools('state sync --verify', tmpDir);
|
|
||||||
assert.ok(result.success, `state sync --verify failed: ${result.error}`);
|
|
||||||
|
|
||||||
const parsed = JSON.parse(result.output);
|
|
||||||
assert.ok(parsed.dry_run, 'dry_run must be true with --verify flag');
|
|
||||||
const planCountChange = parsed.changes.find(c => c.startsWith('Total Plans in Phase:'));
|
|
||||||
assert.ok(planCountChange, `dry-run changes must include Total Plans in Phase; got: ${JSON.stringify(parsed.changes)}`);
|
|
||||||
assert.ok(planCountChange.includes('-> 2'), `dry-run must show correct count of 2; got: "${planCountChange}"`);
|
|
||||||
|
|
||||||
// STATE.md must be unchanged (dry-run): re-run sync --verify and confirm the
|
|
||||||
// same pending change is still reported (if STATE.md had been written, the
|
|
||||||
// change would have been applied and the second run would show no changes).
|
|
||||||
const result2 = runGsdTools('state sync --verify', tmpDir);
|
|
||||||
assert.ok(result2.success, `second dry-run failed: ${result2.error}`);
|
|
||||||
const parsed2 = JSON.parse(result2.output);
|
|
||||||
const planCountChange2 = parsed2.changes.find(c => c.startsWith('Total Plans in Phase:'));
|
|
||||||
assert.ok(planCountChange2, 'repeated dry-run must still report pending change (file was not mutated on disk)');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('sync across multiple phases with nested plans sums correctly', () => {
|
|
||||||
// Phase 01: 2 nested plans, 2 summaries (complete).
|
|
||||||
// Phase 02: 3 nested plans, 1 summary (in progress).
|
|
||||||
// Expected "Total Plans in Phase" = 3 (current/incomplete phase).
|
|
||||||
const phases = [
|
|
||||||
{ dir: '01-alpha', plans: 2, summaries: 2 },
|
|
||||||
{ dir: '02-beta', plans: 3, summaries: 1 },
|
|
||||||
];
|
|
||||||
for (const { dir, plans, summaries } of phases) {
|
|
||||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', dir);
|
|
||||||
const plansDir = path.join(phaseDir, 'plans');
|
|
||||||
fs.mkdirSync(plansDir, { recursive: true });
|
|
||||||
for (let p = 1; p <= plans; p++) {
|
|
||||||
const pad = String(p).padStart(2, '0');
|
|
||||||
fs.writeFileSync(path.join(plansDir, `1-PLAN-${pad}-t.md`), '# Plan\n');
|
|
||||||
}
|
|
||||||
for (let s = 1; s <= summaries; s++) {
|
|
||||||
const pad = String(s).padStart(2, '0');
|
|
||||||
fs.writeFileSync(path.join(plansDir, `1-SUMMARY-${pad}-t.md`), '# Summary\n');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const stateContent = [
|
|
||||||
'# Project State',
|
|
||||||
'',
|
|
||||||
'**Current Phase:** 02',
|
|
||||||
'**Status:** executing',
|
|
||||||
'**Total Plans in Phase:** 0',
|
|
||||||
'**Progress:** [░░░░░░░░░░] 0%',
|
|
||||||
'**Last Activity:** 2026-01-01',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateContent, 'utf-8');
|
|
||||||
|
|
||||||
const result = runGsdTools('state sync', tmpDir);
|
|
||||||
assert.ok(result.success, `state sync failed: ${result.error}`);
|
|
||||||
|
|
||||||
const parsed = JSON.parse(result.output);
|
|
||||||
assert.ok(parsed.synced, 'sync must succeed');
|
|
||||||
// "Total Plans in Phase" reflects the current (incomplete) phase: 02-beta has 3 plans.
|
|
||||||
const planCountChange = parsed.changes.find(c => c.startsWith('Total Plans in Phase:'));
|
|
||||||
assert.ok(planCountChange, `Total Plans in Phase change expected; got: ${JSON.stringify(parsed.changes)}`);
|
|
||||||
assert.ok(planCountChange.includes('-> 3'), `current phase plan count must be 3; got: "${planCountChange}"`);
|
|
||||||
// Progress: computeProgressPercent uses min(plan_fraction, phase_fraction).
|
|
||||||
// plan_fraction = 3 summaries / 5 plans = 60%.
|
|
||||||
// phase_fraction = 1 completed phase / 2 total phases = 50%.
|
|
||||||
// min(60%, 50%) = 50% — the phase cap applies (#3242).
|
|
||||||
const progressChange = parsed.changes.find(c => c.startsWith('Progress:'));
|
|
||||||
assert.ok(progressChange, `Progress change expected; got: ${JSON.stringify(parsed.changes)}`);
|
|
||||||
assert.ok(progressChange.includes('50%'), `progress must reflect nested counts (min(3/5, 1/2)=50%); got: "${progressChange}"`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
/**
|
|
||||||
* GSD Tools Tests — Bug #3275 (CR finding)
|
|
||||||
*
|
|
||||||
* Regression guard: `state-snapshot` must prefer YAML frontmatter scalar
|
|
||||||
* values even when those scalars are numeric (e.g. current_phase: 19) or
|
|
||||||
* boolean — not just when they are strings.
|
|
||||||
*
|
|
||||||
* Prior to the fix, `fmStr` checked `typeof v === 'string'`, so a numeric
|
|
||||||
* frontmatter value like `current_phase: 19` was treated as missing and the
|
|
||||||
* snapshot fell back to body extraction, which could return a stale or
|
|
||||||
* incorrect value.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
|
||||||
|
|
||||||
describe('state-snapshot: fmStr accepts non-string YAML scalars (#3275 CR)', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = createTempProject();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('numeric current_phase in frontmatter wins over body extraction', () => {
|
|
||||||
// YAML parses bare integers as numbers, not strings.
|
|
||||||
// fmStr must not drop the frontmatter value when it is a number.
|
|
||||||
const stateMd = [
|
|
||||||
'---',
|
|
||||||
'gsd_state_version: 1.0',
|
|
||||||
'current_phase: 19',
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'# Project State',
|
|
||||||
'',
|
|
||||||
'**Current Phase:** 03',
|
|
||||||
'**Status:** executing',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateMd);
|
|
||||||
|
|
||||||
const result = runGsdTools('state-snapshot', tmpDir);
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
||||||
|
|
||||||
const output = JSON.parse(result.output);
|
|
||||||
// Frontmatter numeric value must win over bold-body value
|
|
||||||
assert.strictEqual(output.current_phase, '19', 'numeric frontmatter current_phase must be used');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('numeric total_phases in frontmatter wins over body extraction', () => {
|
|
||||||
const stateMd = [
|
|
||||||
'---',
|
|
||||||
'gsd_state_version: 1.0',
|
|
||||||
'total_phases: 7',
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'# Project State',
|
|
||||||
'',
|
|
||||||
'**Total Phases:** 3',
|
|
||||||
'**Status:** executing',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateMd);
|
|
||||||
|
|
||||||
const result = runGsdTools('state-snapshot', tmpDir);
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
||||||
|
|
||||||
const output = JSON.parse(result.output);
|
|
||||||
// Frontmatter says 7, body says 3 — frontmatter must win
|
|
||||||
assert.strictEqual(output.total_phases, 7, 'numeric frontmatter total_phases must be used');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('numeric total_plans_in_phase in frontmatter wins over body extraction', () => {
|
|
||||||
const stateMd = [
|
|
||||||
'---',
|
|
||||||
'gsd_state_version: 1.0',
|
|
||||||
'total_plans_in_phase: 5',
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'# Project State',
|
|
||||||
'',
|
|
||||||
'**Total Plans in Phase:** 2',
|
|
||||||
'**Status:** executing',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateMd);
|
|
||||||
|
|
||||||
const result = runGsdTools('state-snapshot', tmpDir);
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
||||||
|
|
||||||
const output = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(output.total_plans_in_phase, 5, 'numeric frontmatter total_plans_in_phase must be used');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('string current_phase in frontmatter still works (no regression)', () => {
|
|
||||||
const stateMd = [
|
|
||||||
'---',
|
|
||||||
'gsd_state_version: 1.0',
|
|
||||||
"current_phase: '19'",
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'# Project State',
|
|
||||||
'',
|
|
||||||
'**Current Phase:** 03',
|
|
||||||
'**Status:** executing',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateMd);
|
|
||||||
|
|
||||||
const result = runGsdTools('state-snapshot', tmpDir);
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
||||||
|
|
||||||
const output = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(output.current_phase, '19', 'string frontmatter current_phase still works');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('no-frontmatter file still extracts from body (no regression)', () => {
|
|
||||||
const stateMd = [
|
|
||||||
'# Project State',
|
|
||||||
'',
|
|
||||||
'**Current Phase:** 05',
|
|
||||||
'**Total Phases:** 8',
|
|
||||||
'**Status:** paused',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateMd);
|
|
||||||
|
|
||||||
const result = runGsdTools('state-snapshot', tmpDir);
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
||||||
|
|
||||||
const output = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(output.current_phase, '05', 'body extraction still works without frontmatter');
|
|
||||||
assert.strictEqual(output.total_phases, 8, 'numeric body total_phases still extracted');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,396 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
// Regression tests for issue #3286 — three bugs in state.cjs:
|
|
||||||
//
|
|
||||||
// Bug A: cmdStateRecordMetric / cmdStateAddDecision return { recorded: false }
|
|
||||||
// with exit code 0 when their target section is absent. gsd-executor treats
|
|
||||||
// exit 0 as success, silently losing metrics/decisions across an entire phase.
|
|
||||||
// Fix: auto-create the missing section (Bug B subsumes A — silent no-op
|
|
||||||
// disappears). When auto-created, JSON must include created: true.
|
|
||||||
//
|
|
||||||
// Bug B: A fresh STATE.md without ## Performance Metrics or ## Decisions causes
|
|
||||||
// both verbs to silently no-op. DWIM: auto-create the canonical section scaffold
|
|
||||||
// and then write the row/entry, matching state begin-phase / advance-plan behavior.
|
|
||||||
//
|
|
||||||
// Bug C: state record-metric and add-decision must honor --ws <name>, routing
|
|
||||||
// writes to .planning/workstreams/<name>/STATE.md instead of root STATE.md.
|
|
||||||
|
|
||||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
|
|
||||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Fixtures
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Build a minimal STATE.md with all canonical sections */
|
|
||||||
function buildFullStateMd() {
|
|
||||||
return [
|
|
||||||
'# GSD State',
|
|
||||||
'',
|
|
||||||
'## Configuration',
|
|
||||||
'Current Phase: 1',
|
|
||||||
'Current Phase Name: bootstrap',
|
|
||||||
'Total Plans in Phase: 3',
|
|
||||||
'Current Plan: 1',
|
|
||||||
'Status: Executing',
|
|
||||||
'Last Activity: 2026-01-01',
|
|
||||||
'',
|
|
||||||
'## Performance Metrics',
|
|
||||||
'',
|
|
||||||
'| Phase | Plan | Duration | Notes |',
|
|
||||||
'|-------|------|----------|-------|',
|
|
||||||
'',
|
|
||||||
'## Decisions',
|
|
||||||
'',
|
|
||||||
'None yet.',
|
|
||||||
'',
|
|
||||||
'### Blockers',
|
|
||||||
'',
|
|
||||||
'None.',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build a STATE.md WITHOUT Performance Metrics or Decisions sections */
|
|
||||||
function buildBareboneStateMd() {
|
|
||||||
return [
|
|
||||||
'# GSD State',
|
|
||||||
'',
|
|
||||||
'## Configuration',
|
|
||||||
'Current Phase: 1',
|
|
||||||
'Current Phase Name: bootstrap',
|
|
||||||
'Total Plans in Phase: 3',
|
|
||||||
'Current Plan: 1',
|
|
||||||
'Status: Executing',
|
|
||||||
'Last Activity: 2026-01-01',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Group B: auto-create missing sections (DWIM)
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('#3286 Bug B: record-metric auto-creates ## Performance Metrics when missing', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = createTempProject();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('record-metric succeeds when ## Performance Metrics is absent', () => {
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
fs.writeFileSync(statePath, buildBareboneStateMd());
|
|
||||||
|
|
||||||
const result = runGsdTools(
|
|
||||||
['state', 'record-metric', '--phase', '1', '--plan', '1', '--duration', '45min'],
|
|
||||||
tmpDir,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.ok(result.success, `record-metric must succeed (exit 0), got: ${result.error}`);
|
|
||||||
|
|
||||||
const parsed = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(parsed.recorded, true, `recorded must be true, got: ${JSON.stringify(parsed)}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('record-metric with created:true when section was auto-created', () => {
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
fs.writeFileSync(statePath, buildBareboneStateMd());
|
|
||||||
|
|
||||||
const result = runGsdTools(
|
|
||||||
['state', 'record-metric', '--phase', '1', '--plan', '1', '--duration', '45min'],
|
|
||||||
tmpDir,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.ok(result.success, `record-metric must succeed, got: ${result.error}`);
|
|
||||||
const parsed = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(parsed.created, true, `JSON must include created:true when section was auto-created`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('record-metric appends row into auto-created section — verifiable via state snapshot', () => {
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
fs.writeFileSync(statePath, buildBareboneStateMd());
|
|
||||||
|
|
||||||
const result = runGsdTools(
|
|
||||||
['state', 'record-metric', '--phase', '1', '--plan', '2', '--duration', '30min', '--tasks', '5'],
|
|
||||||
tmpDir,
|
|
||||||
);
|
|
||||||
assert.ok(result.success, `record-metric must succeed, got: ${result.error}`);
|
|
||||||
|
|
||||||
// Verify the metric appeared in the file by calling state get to read the section
|
|
||||||
const getResult = runGsdTools(['state', 'get', 'Performance Metrics'], tmpDir);
|
|
||||||
assert.ok(getResult.success, `state get must succeed, got: ${getResult.error}`);
|
|
||||||
|
|
||||||
// Parse JSON to check structural content (no .includes on raw file)
|
|
||||||
const sectionContent = JSON.parse(getResult.output);
|
|
||||||
const sectionText = sectionContent['Performance Metrics'] || '';
|
|
||||||
// Must contain a row referencing Phase 1 P2
|
|
||||||
assert.ok(
|
|
||||||
sectionText.includes('Phase 1 P2') || sectionText.includes('| Phase 1 P2'),
|
|
||||||
`Performance Metrics section must contain the appended row. Got section: ${sectionText}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('record-metric on state with existing section still works (no regression)', () => {
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
fs.writeFileSync(statePath, buildFullStateMd());
|
|
||||||
|
|
||||||
const result = runGsdTools(
|
|
||||||
['state', 'record-metric', '--phase', '2', '--plan', '1', '--duration', '1h'],
|
|
||||||
tmpDir,
|
|
||||||
);
|
|
||||||
assert.ok(result.success, `record-metric must succeed on existing section, got: ${result.error}`);
|
|
||||||
const parsed = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(parsed.recorded, true, `recorded must be true`);
|
|
||||||
// created should be absent or false when section already existed
|
|
||||||
assert.ok(!parsed.created, `created must be absent/false when section existed`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('#3286 Bug B: add-decision auto-creates ## Decisions when missing', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = createTempProject();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('add-decision succeeds when ## Decisions is absent', () => {
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
fs.writeFileSync(statePath, buildBareboneStateMd());
|
|
||||||
|
|
||||||
const result = runGsdTools(
|
|
||||||
['state', 'add-decision', '--phase', '1', '--summary', 'Use TypeScript for type safety'],
|
|
||||||
tmpDir,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.ok(result.success, `add-decision must succeed (exit 0), got: ${result.error}`);
|
|
||||||
const parsed = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(parsed.added, true, `added must be true, got: ${JSON.stringify(parsed)}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('add-decision with created:true when section was auto-created', () => {
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
fs.writeFileSync(statePath, buildBareboneStateMd());
|
|
||||||
|
|
||||||
const result = runGsdTools(
|
|
||||||
['state', 'add-decision', '--phase', '1', '--summary', 'Use Redis for caching'],
|
|
||||||
tmpDir,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.ok(result.success, `add-decision must succeed, got: ${result.error}`);
|
|
||||||
const parsed = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(parsed.created, true, `JSON must include created:true when Decisions section auto-created`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('add-decision appended entry is visible in state get', () => {
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
fs.writeFileSync(statePath, buildBareboneStateMd());
|
|
||||||
|
|
||||||
const summary = 'Adopt PostgreSQL over MySQL for JSONB support';
|
|
||||||
const result = runGsdTools(
|
|
||||||
['state', 'add-decision', '--phase', '2', '--summary', summary],
|
|
||||||
tmpDir,
|
|
||||||
);
|
|
||||||
assert.ok(result.success, `add-decision must succeed, got: ${result.error}`);
|
|
||||||
|
|
||||||
// Verify via state get (structured), not raw file grep
|
|
||||||
const getResult = runGsdTools(['state', 'get', 'Decisions'], tmpDir);
|
|
||||||
assert.ok(getResult.success, `state get Decisions must succeed, got: ${getResult.error}`);
|
|
||||||
|
|
||||||
const sectionContent = JSON.parse(getResult.output);
|
|
||||||
const sectionText = sectionContent['Decisions'] || '';
|
|
||||||
assert.ok(
|
|
||||||
sectionText.includes(summary),
|
|
||||||
`Decisions section must contain the appended decision. Got: ${sectionText}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('add-decision on state with existing Decisions section works (no regression)', () => {
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
fs.writeFileSync(statePath, buildFullStateMd());
|
|
||||||
|
|
||||||
const result = runGsdTools(
|
|
||||||
['state', 'add-decision', '--phase', '1', '--summary', 'Use monorepo layout'],
|
|
||||||
tmpDir,
|
|
||||||
);
|
|
||||||
assert.ok(result.success, `add-decision must succeed on existing section, got: ${result.error}`);
|
|
||||||
const parsed = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(parsed.added, true, `added must be true`);
|
|
||||||
assert.ok(!parsed.created, `created must be absent/false when section already existed`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Group A: exit code contract (covered by Bug B fix — no silent no-op)
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('#3286 Bug A: record-metric / add-decision never silently no-op', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = createTempProject();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('record-metric always has recorded:true (never silent false)', () => {
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
// Minimal state — no Performance Metrics section
|
|
||||||
fs.writeFileSync(statePath, buildBareboneStateMd());
|
|
||||||
|
|
||||||
const result = runGsdTools(
|
|
||||||
['state', 'record-metric', '--phase', '1', '--plan', '1', '--duration', '20min'],
|
|
||||||
tmpDir,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Must exit 0 AND recorded must be true (auto-created or found)
|
|
||||||
assert.ok(result.success, `record-metric must exit 0, got stderr: ${result.error}`);
|
|
||||||
const parsed = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(
|
|
||||||
parsed.recorded,
|
|
||||||
true,
|
|
||||||
`recorded must be true — section auto-create should prevent silent false. Got: ${JSON.stringify(parsed)}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('add-decision always has added:true (never silent false)', () => {
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
fs.writeFileSync(statePath, buildBareboneStateMd());
|
|
||||||
|
|
||||||
const result = runGsdTools(
|
|
||||||
['state', 'add-decision', '--phase', '1', '--summary', 'Prefer composition over inheritance'],
|
|
||||||
tmpDir,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.ok(result.success, `add-decision must exit 0, got stderr: ${result.error}`);
|
|
||||||
const parsed = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(
|
|
||||||
parsed.added,
|
|
||||||
true,
|
|
||||||
`added must be true — section auto-create should prevent silent false. Got: ${JSON.stringify(parsed)}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Group C: workstream routing — writes go to workstream STATE.md, not root
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('#3286 Bug C: record-metric / add-decision honor --ws routing', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = createTempProject();
|
|
||||||
|
|
||||||
// Create root STATE.md with Performance Metrics + Decisions sections
|
|
||||||
const rootStatePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
fs.writeFileSync(rootStatePath, buildFullStateMd());
|
|
||||||
|
|
||||||
// Create workstream foo with its own STATE.md (full sections)
|
|
||||||
const wsDir = path.join(tmpDir, '.planning', 'workstreams', 'foo');
|
|
||||||
fs.mkdirSync(wsDir, { recursive: true });
|
|
||||||
const wsStatePath = path.join(wsDir, 'STATE.md');
|
|
||||||
fs.writeFileSync(wsStatePath, buildFullStateMd());
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('record-metric --ws foo writes to workstream STATE.md, not root', () => {
|
|
||||||
const result = runGsdTools(
|
|
||||||
['state', 'record-metric', '--phase', '1', '--plan', '1', '--duration', '10min', '--ws', 'foo'],
|
|
||||||
tmpDir,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.ok(result.success, `record-metric --ws foo must succeed, got: ${result.error}`);
|
|
||||||
const parsed = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(parsed.recorded, true, `recorded must be true`);
|
|
||||||
|
|
||||||
// Workstream STATE.md should have the row; root STATE.md should NOT
|
|
||||||
const rootGet = runGsdTools(['state', 'get', 'Performance Metrics'], tmpDir);
|
|
||||||
assert.ok(rootGet.success, `state get root must succeed, got: ${rootGet.error}`);
|
|
||||||
const rootContent = JSON.parse(rootGet.output)['Performance Metrics'] || '';
|
|
||||||
assert.ok(
|
|
||||||
!rootContent.includes('Phase 1 P1'),
|
|
||||||
`Root STATE.md must NOT have the metric row. Got: ${rootContent}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const wsGet = runGsdTools(['state', 'get', 'Performance Metrics', '--ws', 'foo'], tmpDir);
|
|
||||||
assert.ok(wsGet.success, `state get --ws foo must succeed, got: ${wsGet.error}`);
|
|
||||||
const wsContent = JSON.parse(wsGet.output)['Performance Metrics'] || '';
|
|
||||||
assert.ok(
|
|
||||||
wsContent.includes('Phase 1 P1'),
|
|
||||||
`Workstream foo STATE.md must have the metric row. Got: ${wsContent}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('add-decision --ws foo writes to workstream STATE.md, not root', () => {
|
|
||||||
const summary = 'Adopt event-sourcing for audit trail';
|
|
||||||
const result = runGsdTools(
|
|
||||||
['state', 'add-decision', '--phase', '1', '--summary', summary, '--ws', 'foo'],
|
|
||||||
tmpDir,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.ok(result.success, `add-decision --ws foo must succeed, got: ${result.error}`);
|
|
||||||
const parsed = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(parsed.added, true, `added must be true`);
|
|
||||||
|
|
||||||
// Root STATE.md must NOT have the decision
|
|
||||||
const rootGet = runGsdTools(['state', 'get', 'Decisions'], tmpDir);
|
|
||||||
assert.ok(rootGet.success, `state get root must succeed, got: ${rootGet.error}`);
|
|
||||||
const rootContent = JSON.parse(rootGet.output)['Decisions'] || '';
|
|
||||||
assert.ok(
|
|
||||||
!rootContent.includes(summary),
|
|
||||||
`Root STATE.md must NOT have the decision. Got: ${rootContent}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Workstream STATE.md must have the decision
|
|
||||||
const wsGet = runGsdTools(['state', 'get', 'Decisions', '--ws', 'foo'], tmpDir);
|
|
||||||
assert.ok(wsGet.success, `state get --ws foo must succeed, got: ${wsGet.error}`);
|
|
||||||
const wsContent = JSON.parse(wsGet.output)['Decisions'] || '';
|
|
||||||
assert.ok(
|
|
||||||
wsContent.includes(summary),
|
|
||||||
`Workstream foo STATE.md must have the decision. Got: ${wsContent}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('record-metric --ws foo auto-creates section in workstream STATE.md when missing', () => {
|
|
||||||
// Create a workstream without Performance Metrics section
|
|
||||||
const wsDir = path.join(tmpDir, '.planning', 'workstreams', 'bar');
|
|
||||||
fs.mkdirSync(wsDir, { recursive: true });
|
|
||||||
fs.writeFileSync(path.join(wsDir, 'STATE.md'), buildBareboneStateMd());
|
|
||||||
|
|
||||||
const result = runGsdTools(
|
|
||||||
['state', 'record-metric', '--phase', '1', '--plan', '1', '--duration', '5min', '--ws', 'bar'],
|
|
||||||
tmpDir,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.ok(result.success, `record-metric --ws bar must succeed, got: ${result.error}`);
|
|
||||||
const parsed = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(parsed.recorded, true, `recorded must be true`);
|
|
||||||
assert.strictEqual(parsed.created, true, `created must be true when section auto-created in workstream`);
|
|
||||||
|
|
||||||
// Root STATE.md must remain untouched
|
|
||||||
const rootGet = runGsdTools(['state', 'get', 'Performance Metrics'], tmpDir);
|
|
||||||
assert.ok(rootGet.success);
|
|
||||||
const rootContent = JSON.parse(rootGet.output)['Performance Metrics'] || '';
|
|
||||||
assert.ok(
|
|
||||||
!rootContent.includes('Phase 1 P1'),
|
|
||||||
`Root STATE.md must not be written when --ws bar is used`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
|
|
||||||
|
|
||||||
function seedState(tmpDir, planLine = '1 of 2') {
|
|
||||||
const state = `# Project State
|
|
||||||
|
|
||||||
**Status:** executing
|
|
||||||
**Current Phase:** 1
|
|
||||||
|
|
||||||
## Current Position
|
|
||||||
Phase: 1 of 1
|
|
||||||
Plan: ${planLine}
|
|
||||||
Status: Ready
|
|
||||||
Last activity: 2026-01-01
|
|
||||||
Budget: $2,500 max test
|
|
||||||
|
|
||||||
## Session Continuity
|
|
||||||
Last session: 2026-01-01
|
|
||||||
`;
|
|
||||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), state, 'utf8');
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseStateFile(tmpDir) {
|
|
||||||
const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf8');
|
|
||||||
const sections = {};
|
|
||||||
const keyCountsBySection = {};
|
|
||||||
let currentSection = '__root__';
|
|
||||||
sections[currentSection] = {};
|
|
||||||
keyCountsBySection[currentSection] = {};
|
|
||||||
|
|
||||||
for (const rawLine of content.split(/\r?\n/u)) {
|
|
||||||
const headingMatch = /^##\s+(.+)$/u.exec(rawLine);
|
|
||||||
if (headingMatch) {
|
|
||||||
currentSection = headingMatch[1].trim();
|
|
||||||
sections[currentSection] = sections[currentSection] || {};
|
|
||||||
keyCountsBySection[currentSection] = keyCountsBySection[currentSection] || {};
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmed = rawLine.trim();
|
|
||||||
if (!trimmed) continue;
|
|
||||||
|
|
||||||
const boldFieldMatch = /^\*\*([^*]+)\*\*:\s*(.*)$/u.exec(trimmed);
|
|
||||||
if (boldFieldMatch) {
|
|
||||||
const key = boldFieldMatch[1].trim();
|
|
||||||
const value = boldFieldMatch[2].trim();
|
|
||||||
sections[currentSection][key] = value;
|
|
||||||
keyCountsBySection[currentSection][key] = (keyCountsBySection[currentSection][key] || 0) + 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const colonIndex = trimmed.indexOf(':');
|
|
||||||
if (colonIndex <= 0) continue;
|
|
||||||
|
|
||||||
const key = trimmed.slice(0, colonIndex).trim();
|
|
||||||
const value = trimmed.slice(colonIndex + 1).trim();
|
|
||||||
sections[currentSection][key] = value;
|
|
||||||
keyCountsBySection[currentSection][key] = (keyCountsBySection[currentSection][key] || 0) + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { content, sections, keyCountsBySection };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('bug #3454: state mutation must preserve literal $N amounts', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = createTempProject('bug-3454-');
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('state advance-plan keeps Current Position dollar amount literal', () => {
|
|
||||||
seedState(tmpDir, '1 of 20');
|
|
||||||
const result = runGsdTools(['state', 'advance-plan'], tmpDir);
|
|
||||||
assert.equal(result.success, true, `state advance-plan failed: ${result.error || result.output}`);
|
|
||||||
|
|
||||||
const parsed = parseStateFile(tmpDir);
|
|
||||||
const currentPosition = parsed.sections['Current Position'] || {};
|
|
||||||
assert.equal(currentPosition.Budget, '$2,500 max test');
|
|
||||||
assert.equal((parsed.keyCountsBySection['Current Position'] || {}).Budget, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('state begin-phase keeps Current Position dollar amount literal', () => {
|
|
||||||
seedState(tmpDir);
|
|
||||||
const result = runGsdTools(['state', 'begin-phase', '--phase', '1', '--name', 'setup', '--plans', '2'], tmpDir);
|
|
||||||
assert.equal(result.success, true, `state begin-phase failed: ${result.error || result.output}`);
|
|
||||||
|
|
||||||
const parsed = parseStateFile(tmpDir);
|
|
||||||
const currentPosition = parsed.sections['Current Position'] || {};
|
|
||||||
assert.equal(currentPosition.Budget, '$2,500 max test');
|
|
||||||
assert.equal((parsed.keyCountsBySection['Current Position'] || {}).Budget, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('state complete-phase keeps Current Position dollar amount literal', () => {
|
|
||||||
seedState(tmpDir);
|
|
||||||
const result = runGsdTools(['state', 'complete-phase', '--phase', '1'], tmpDir);
|
|
||||||
assert.equal(result.success, true, `state complete-phase failed: ${result.error || result.output}`);
|
|
||||||
|
|
||||||
const parsed = parseStateFile(tmpDir);
|
|
||||||
const currentPosition = parsed.sections['Current Position'] || {};
|
|
||||||
assert.equal(currentPosition.Budget, '$2,500 max test');
|
|
||||||
assert.equal((parsed.keyCountsBySection['Current Position'] || {}).Budget, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('repeated state advance-plan stays size-bounded with dollar amounts', () => {
|
|
||||||
seedState(tmpDir, '1 of 20');
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
let stabilizedSize = null;
|
|
||||||
for (let i = 0; i < 8; i += 1) {
|
|
||||||
const result = runGsdTools(['state', 'advance-plan'], tmpDir);
|
|
||||||
assert.equal(result.success, true, `iteration ${i + 1} failed: ${result.error || result.output}`);
|
|
||||||
if (i === 0) stabilizedSize = fs.statSync(statePath).size;
|
|
||||||
}
|
|
||||||
|
|
||||||
const endSize = fs.statSync(statePath).size;
|
|
||||||
const growth = endSize / stabilizedSize;
|
|
||||||
assert.ok(growth <= 1.5, `expected <=1.5x growth after first write, got ${growth.toFixed(2)}x (${stabilizedSize} -> ${endSize})`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
// allow-test-rule: source-text-is-the-product
|
|
||||||
// State.md is the deployed artifact; asserting on its literal text content
|
|
||||||
// tests the deployed contract.
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Regression test for #3489
|
|
||||||
*
|
|
||||||
* `gsd state complete-phase --phase <N>` was non-idempotent. Re-invoking it
|
|
||||||
* on a phase already marked complete in STATE.md silently rolled STATE.md
|
|
||||||
* back to that phase's moment-of-completion — overwriting Status, Last
|
|
||||||
* Activity, Current Position and the body Status/Phase with stale values
|
|
||||||
* derived from the just-completed phase.
|
|
||||||
*
|
|
||||||
* Expected: when the target phase is already marked complete (and STATE.md
|
|
||||||
* has clearly advanced past it — e.g. a later phase is now in progress or
|
|
||||||
* inserted), `complete-phase` must be a no-op. No STATE.md write at all.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
|
|
||||||
|
|
||||||
describe('bug #3489: state complete-phase must be idempotent', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = createTempProject('bug-3489-');
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('re-running complete-phase on an already-complete phase does not roll STATE.md back', () => {
|
|
||||||
// STATE.md as it would appear AFTER phase 02.2 was legitimately completed
|
|
||||||
// AND a follow-up Phase 02.2.1 has since been inserted as in-progress.
|
|
||||||
// Re-invoking `state complete-phase --phase 02.2` from a downstream tool
|
|
||||||
// (e.g. a re-run of /gsd-execute-phase) must NOT regress this content.
|
|
||||||
const stateMd = [
|
|
||||||
'---',
|
|
||||||
'milestone: v1.0',
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'# State',
|
|
||||||
'',
|
|
||||||
'**Status:** in-progress',
|
|
||||||
'**Current Phase:** 02.2.1',
|
|
||||||
'**Last Activity:** 2026-05-13',
|
|
||||||
'**Last Activity Description:** Phase 02.2.1 inserted (urgent — gates Phase 5)',
|
|
||||||
'',
|
|
||||||
'## Current Position',
|
|
||||||
'',
|
|
||||||
'Phase: 02.2.1 — Not planned yet',
|
|
||||||
'Status: Phase 02.2.1 inserted (urgent — gates Phase 5)',
|
|
||||||
'Last activity: 2026-05-13 -- Phase 02.2.1 inserted (urgent — gates Phase 5)',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
fs.writeFileSync(statePath, stateMd, 'utf8');
|
|
||||||
const before = fs.readFileSync(statePath, 'utf8');
|
|
||||||
|
|
||||||
const result = runGsdTools(['state', 'complete-phase', '--phase', '02.2'], tmpDir);
|
|
||||||
assert.ok(result.success, `command should not error, got: ${result.error || result.output}`);
|
|
||||||
|
|
||||||
const after = fs.readFileSync(statePath, 'utf8');
|
|
||||||
|
|
||||||
// Hard assertion: file is byte-identical to its pre-call snapshot.
|
|
||||||
assert.equal(
|
|
||||||
after,
|
|
||||||
before,
|
|
||||||
`STATE.md must not be rewritten when phase is already complete.\n\n--- before ---\n${before}\n--- after ---\n${after}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Output should advertise the no-op so downstream consumers can detect it.
|
|
||||||
let payload = null;
|
|
||||||
try { payload = JSON.parse(result.output); } catch (_) { /* ignore */ }
|
|
||||||
assert.ok(payload && typeof payload === 'object', `expected JSON payload, got: ${result.output}`);
|
|
||||||
assert.deepEqual(payload.updated, [], `expected empty updated list, got: ${JSON.stringify(payload.updated)}`);
|
|
||||||
assert.equal(payload.phase, '02.2');
|
|
||||||
assert.equal(payload.idempotent, true, `expected idempotent:true flag, got: ${JSON.stringify(payload)}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('completing the currently in-progress phase still works normally (no false-positive idempotency)', () => {
|
|
||||||
// Sanity check: the guard must not fire on the legitimate first completion.
|
|
||||||
const stateMd = [
|
|
||||||
'---',
|
|
||||||
'milestone: v1.0',
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'# State',
|
|
||||||
'',
|
|
||||||
'**Status:** in-progress',
|
|
||||||
'**Current Phase:** 03',
|
|
||||||
'**Last Activity:** 2026-05-13',
|
|
||||||
'',
|
|
||||||
'## Current Position',
|
|
||||||
'',
|
|
||||||
'Phase: 03',
|
|
||||||
'Status: Phase 03 executing',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
fs.writeFileSync(statePath, stateMd, 'utf8');
|
|
||||||
|
|
||||||
const result = runGsdTools(['state', 'complete-phase', '--phase', '03'], tmpDir);
|
|
||||||
assert.ok(result.success, `command failed: ${result.error || result.output}`);
|
|
||||||
|
|
||||||
const after = fs.readFileSync(statePath, 'utf8');
|
|
||||||
assert.ok(
|
|
||||||
after.includes('**Status:** Phase 03 complete'),
|
|
||||||
`expected Status updated to "Phase 03 complete", got:\n${after}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const payload = JSON.parse(result.output);
|
|
||||||
assert.notEqual(payload.idempotent, true, 'first completion must not be flagged idempotent');
|
|
||||||
assert.ok(Array.isArray(payload.updated) && payload.updated.length > 0, 'expected non-empty updated list');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,379 +0,0 @@
|
|||||||
/**
|
|
||||||
* Regression tests for bug #3537
|
|
||||||
*
|
|
||||||
* Phase state verbs must match a canonical phase id against ROADMAP.md prose
|
|
||||||
* regardless of zero-padding on either side: the skills pass the padded form
|
|
||||||
* (`02.7`) after resolving the phase directory, but human-authored ROADMAP
|
|
||||||
* prose is conventionally un-padded (`### Phase 2.7:`, `- [ ] **Phase 2.7:**`).
|
|
||||||
*
|
|
||||||
* v1.42.1 added `phaseMarkdownRegexSource()` which renders `0*<integer><...>`
|
|
||||||
* — padding-tolerant on both sides — but wired it into only 1 of 8 call sites.
|
|
||||||
* The other 7 used raw `escapeRegex(phaseNum)` or `0*${escapeRegex(...)}`
|
|
||||||
* (tolerated extra padding, not missing), so passing the padded form silently
|
|
||||||
* no-op'd and the verbs returned success while ROADMAP.md was unchanged.
|
|
||||||
*
|
|
||||||
* Parity assertion (per CONTEXT.md DEFECT.GENERATIVE-FIX): for each verb,
|
|
||||||
* running with the padded form must produce the same ROADMAP.md as running
|
|
||||||
* with the un-padded form against an identical fixture. Per-site fixes
|
|
||||||
* without a parity test let the next call-site drift back undetected.
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const { describe, test } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
const os = require('node:os');
|
|
||||||
const { execFileSync } = require('node:child_process');
|
|
||||||
|
|
||||||
const { cleanup } = require('./helpers.cjs');
|
|
||||||
|
|
||||||
const gsdTools = path.resolve(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
|
||||||
|
|
||||||
function run(args, cwd) {
|
|
||||||
try {
|
|
||||||
return {
|
|
||||||
stdout: execFileSync('node', [gsdTools, ...args], {
|
|
||||||
cwd,
|
|
||||||
timeout: 15000,
|
|
||||||
encoding: 'utf-8',
|
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
|
||||||
}),
|
|
||||||
ok: true,
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
return {
|
|
||||||
stdout: (e.stdout && e.stdout.toString()) || '',
|
|
||||||
stderr: (e.stderr && e.stderr.toString()) || '',
|
|
||||||
ok: false,
|
|
||||||
code: e.status,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a planning fixture with project_code='CK', padded phase directory
|
|
||||||
* (`CK-02.7-meta-lead-ads/`), and un-padded ROADMAP prose (`Phase 2.7`).
|
|
||||||
* This mirrors the reporter's environment in #3537 exactly.
|
|
||||||
*/
|
|
||||||
function setupFixture(tmpDir, opts = {}) {
|
|
||||||
const {
|
|
||||||
projectCode = 'CK',
|
|
||||||
paddedId = '02.7',
|
|
||||||
unpaddedId = '2.7',
|
|
||||||
extraPhases = [],
|
|
||||||
} = opts;
|
|
||||||
|
|
||||||
const planningDir = path.join(tmpDir, '.planning');
|
|
||||||
fs.mkdirSync(planningDir, { recursive: true });
|
|
||||||
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(planningDir, 'config.json'),
|
|
||||||
JSON.stringify({ project_code: projectCode })
|
|
||||||
);
|
|
||||||
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(planningDir, 'STATE.md'),
|
|
||||||
`---\ncurrent_phase: ${unpaddedId}\nstatus: executing\n---\n# State\n`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Padded phase directory with one plan + matching summary so the phase
|
|
||||||
// is "complete" for phase-complete and update-plan-progress verbs.
|
|
||||||
const phaseDirName = `${projectCode}-${paddedId}-meta-lead-ads`;
|
|
||||||
const phaseDir = path.join(planningDir, 'phases', phaseDirName);
|
|
||||||
fs.mkdirSync(phaseDir, { recursive: true });
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(phaseDir, `${paddedId}-01-PLAN.md`),
|
|
||||||
`---\nphase: ${unpaddedId}\nplan: 1\nwave: 1\n---\n# Plan 1\n`
|
|
||||||
);
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(phaseDir, `${paddedId}-01-SUMMARY.md`),
|
|
||||||
'---\nstatus: complete\n---\n# Summary\nDone.'
|
|
||||||
);
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(phaseDir, `${paddedId}-VERIFICATION.md`),
|
|
||||||
'---\nstatus: passed\nscore: "1/1"\n---\n# Verification\nPassed.\n'
|
|
||||||
);
|
|
||||||
|
|
||||||
const extra = extraPhases
|
|
||||||
.map((p) => `- [ ] **Phase ${p.id}: ${p.name}**`)
|
|
||||||
.join('\n');
|
|
||||||
|
|
||||||
const roadmap = [
|
|
||||||
'# Roadmap',
|
|
||||||
'',
|
|
||||||
'## v1.0 Milestone',
|
|
||||||
'',
|
|
||||||
`- [ ] **Phase ${unpaddedId}: Meta Lead Ads**`,
|
|
||||||
extra,
|
|
||||||
'',
|
|
||||||
'## Progress',
|
|
||||||
'',
|
|
||||||
'| Phase | Plans | Status | Completed |',
|
|
||||||
'|-------|-------|--------|-----------|',
|
|
||||||
`| ${unpaddedId} Meta Lead Ads | 0/1 | Planned | - |`,
|
|
||||||
'',
|
|
||||||
`### Phase ${unpaddedId}: Meta Lead Ads`,
|
|
||||||
'',
|
|
||||||
'**Goal:** ship the thing',
|
|
||||||
'**Plans:** 0 plans',
|
|
||||||
'',
|
|
||||||
'Plans:',
|
|
||||||
`- [ ] ${paddedId}-01-PLAN.md`,
|
|
||||||
'',
|
|
||||||
...extraPhases.flatMap((p) => [
|
|
||||||
`### Phase ${p.id}: ${p.name}`,
|
|
||||||
'',
|
|
||||||
'**Goal:** stub',
|
|
||||||
'**Plans:** 0 plans',
|
|
||||||
'',
|
|
||||||
'Plans:',
|
|
||||||
`- [ ] ${p.id}-01-PLAN.md`,
|
|
||||||
'',
|
|
||||||
]),
|
|
||||||
]
|
|
||||||
.filter((l) => l !== '')
|
|
||||||
.join('\n') + '\n';
|
|
||||||
|
|
||||||
fs.writeFileSync(path.join(planningDir, 'ROADMAP.md'), roadmap);
|
|
||||||
|
|
||||||
return {
|
|
||||||
planningDir,
|
|
||||||
roadmapPath: path.join(planningDir, 'ROADMAP.md'),
|
|
||||||
phaseDir,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run a verb in two parallel fixtures — one passing the padded form, one
|
|
||||||
* passing the un-padded form — then compare the resulting ROADMAP.md bytes.
|
|
||||||
* Any divergence means the verb's regex did not tolerate padding on at least
|
|
||||||
* one side.
|
|
||||||
*/
|
|
||||||
function expectParity({ verbWithPadded, verbWithUnpadded, fixtureOpts }) {
|
|
||||||
const tmpA = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3537-A-'));
|
|
||||||
const tmpB = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3537-B-'));
|
|
||||||
try {
|
|
||||||
const a = setupFixture(tmpA, fixtureOpts);
|
|
||||||
const b = setupFixture(tmpB, fixtureOpts);
|
|
||||||
|
|
||||||
const ra = verbWithPadded(tmpA);
|
|
||||||
const rb = verbWithUnpadded(tmpB);
|
|
||||||
|
|
||||||
const aRoadmap = fs.readFileSync(a.roadmapPath, 'utf-8');
|
|
||||||
const bRoadmap = fs.readFileSync(b.roadmapPath, 'utf-8');
|
|
||||||
|
|
||||||
return { aRoadmap, bRoadmap, ra, rb };
|
|
||||||
} finally {
|
|
||||||
cleanup(tmpA);
|
|
||||||
cleanup(tmpB);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('bug #3537: phase verbs accept padded ids against un-padded ROADMAP prose', () => {
|
|
||||||
test('phase complete: padded 02.7 and un-padded 2.7 produce identical ROADMAP', () => {
|
|
||||||
const { aRoadmap, bRoadmap } = expectParity({
|
|
||||||
fixtureOpts: {},
|
|
||||||
verbWithPadded: (cwd) => run(['phase', 'complete', '02.7'], cwd),
|
|
||||||
verbWithUnpadded: (cwd) => run(['phase', 'complete', '2.7'], cwd),
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(
|
|
||||||
aRoadmap,
|
|
||||||
bRoadmap,
|
|
||||||
'padded `02.7` must mutate ROADMAP identically to un-padded `2.7`'
|
|
||||||
);
|
|
||||||
// And the canonical mutation must have actually happened (otherwise
|
|
||||||
// both forms could be silently no-op'ing and still produce identical
|
|
||||||
// output — a vacuous parity pass).
|
|
||||||
assert.match(
|
|
||||||
aRoadmap,
|
|
||||||
/- \[x\] \*\*Phase 2\.7:/,
|
|
||||||
'overview checkbox should be flipped under both invocations'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('roadmap get-phase: padded 02.7 returns the same section as un-padded 2.7', () => {
|
|
||||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3537-get-'));
|
|
||||||
try {
|
|
||||||
setupFixture(tmp, {});
|
|
||||||
const padded = run(['roadmap', 'get-phase', '02.7', '--raw'], tmp);
|
|
||||||
const unpadded = run(['roadmap', 'get-phase', '2.7', '--raw'], tmp);
|
|
||||||
|
|
||||||
assert.equal(
|
|
||||||
padded.stdout,
|
|
||||||
unpadded.stdout,
|
|
||||||
'padded and un-padded ids must return identical sections'
|
|
||||||
);
|
|
||||||
// Non-vacuous guard: both forms must have actually returned a section
|
|
||||||
// (the bug we're fixing was that the padded form returned an empty
|
|
||||||
// string while reporting success).
|
|
||||||
assert.ok(
|
|
||||||
padded.stdout.trim().length > 0,
|
|
||||||
'verb must return non-empty section under both invocations'
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
cleanup(tmp);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('phase next-decimal: padded 02 finds decimals in un-padded ROADMAP', () => {
|
|
||||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3537-nd-'));
|
|
||||||
try {
|
|
||||||
setupFixture(tmp, {
|
|
||||||
paddedId: '02.7',
|
|
||||||
unpaddedId: '2.7',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Padded base `02` must discover the existing decimal `2.7` from the
|
|
||||||
// un-padded heading and propose `2.8` (or higher) as next.
|
|
||||||
const padded = run(['phase', 'next-decimal', '02', '--raw'], tmp);
|
|
||||||
const unpadded = run(['phase', 'next-decimal', '2', '--raw'], tmp);
|
|
||||||
|
|
||||||
assert.equal(
|
|
||||||
padded.stdout,
|
|
||||||
unpadded.stdout,
|
|
||||||
'next-decimal must produce identical JSON for padded and un-padded base'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Sanity: the existing 2.7 must be reflected. If the prose-scan regex
|
|
||||||
// silently failed to match `Phase 2.7`, the result would skip 2.7 and
|
|
||||||
// wrongly propose 2.1 as next. `phase next-decimal --raw` emits the
|
|
||||||
// next id as plain text (`02.8`), so trimmed string equality is the
|
|
||||||
// typed assertion shape (no raw-text regex matching — lint policy).
|
|
||||||
const nextDecimalPadded = padded.stdout.trim();
|
|
||||||
assert.notEqual(
|
|
||||||
nextDecimalPadded,
|
|
||||||
'02.1',
|
|
||||||
'must not propose 02.1 when 2.7 already exists in ROADMAP'
|
|
||||||
);
|
|
||||||
assert.notEqual(
|
|
||||||
nextDecimalPadded,
|
|
||||||
'2.1',
|
|
||||||
'must not propose 2.1 when 2.7 already exists in ROADMAP'
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
cleanup(tmp);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('phase insert: padded base 02 finds anchor in un-padded ROADMAP', () => {
|
|
||||||
const tmpA = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3537-ins-A-'));
|
|
||||||
const tmpB = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3537-ins-B-'));
|
|
||||||
try {
|
|
||||||
// Use a phase 2 (no decimal) base so insert proposes 2.1.
|
|
||||||
const optsA = {
|
|
||||||
paddedId: '02',
|
|
||||||
unpaddedId: '2',
|
|
||||||
};
|
|
||||||
setupFixture(tmpA, optsA);
|
|
||||||
setupFixture(tmpB, optsA);
|
|
||||||
|
|
||||||
const padded = run(['phase', 'insert', '02', 'urgent extension'], tmpA);
|
|
||||||
const unpadded = run(['phase', 'insert', '2', 'urgent extension'], tmpB);
|
|
||||||
|
|
||||||
// Both invocations should succeed (exit 0) — passing the padded base
|
|
||||||
// against un-padded prose used to error "Phase 02 not found".
|
|
||||||
assert.ok(
|
|
||||||
padded.ok,
|
|
||||||
`padded form must succeed, got code=${padded.code}, stderr=${padded.stderr}`
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
unpadded.ok,
|
|
||||||
`un-padded form must succeed, got code=${unpadded.code}`
|
|
||||||
);
|
|
||||||
|
|
||||||
const aRoadmap = fs.readFileSync(
|
|
||||||
path.join(tmpA, '.planning', 'ROADMAP.md'),
|
|
||||||
'utf-8'
|
|
||||||
);
|
|
||||||
|
|
||||||
// The new header may be rendered as `Phase 02.1` or `Phase 2.1`
|
|
||||||
// (normalizePhaseName pads to 2 digits today; that is pre-existing
|
|
||||||
// behavior, not the subject of #3537). The critical assertion for
|
|
||||||
// this verb is "padded form found the anchor and the insertion
|
|
||||||
// happened" — full byte-parity is gated by an unrelated `Depends on:
|
|
||||||
// Phase ${afterPhase}` echo bug that lies outside #3537's scope.
|
|
||||||
assert.match(
|
|
||||||
aRoadmap,
|
|
||||||
/### Phase 0?2\.1: urgent extension/,
|
|
||||||
'padded form must insert the new decimal phase header'
|
|
||||||
);
|
|
||||||
// Reference `tmpB` to ensure cleanup runs and keep it alive in the
|
|
||||||
// closure — also a smoke-check that the un-padded sibling did not
|
|
||||||
// crash mid-run.
|
|
||||||
assert.ok(fs.existsSync(path.join(tmpB, '.planning', 'ROADMAP.md')));
|
|
||||||
} finally {
|
|
||||||
cleanup(tmpA);
|
|
||||||
cleanup(tmpB);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('roadmap annotate-dependencies: padded 02.7 finds phase section', () => {
|
|
||||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3537-ann-'));
|
|
||||||
try {
|
|
||||||
const { roadmapPath } = setupFixture(tmp, {});
|
|
||||||
const before = fs.readFileSync(roadmapPath, 'utf-8');
|
|
||||||
|
|
||||||
const padded = run(
|
|
||||||
['roadmap', 'annotate-dependencies', '02.7'],
|
|
||||||
tmp
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
padded.ok,
|
|
||||||
`padded form must succeed, got code=${padded.code}, stderr=${padded.stderr}`
|
|
||||||
);
|
|
||||||
|
|
||||||
const after = fs.readFileSync(roadmapPath, 'utf-8');
|
|
||||||
// The annotation may be a no-op if there's only one wave and no
|
|
||||||
// cross-cutting truths, but the verb must have reached the phase
|
|
||||||
// section. Confirm by running parity against un-padded form on a
|
|
||||||
// separate fixture and asserting equality.
|
|
||||||
const tmp2 = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3537-ann2-'));
|
|
||||||
try {
|
|
||||||
const { roadmapPath: rp2 } = setupFixture(tmp2, {});
|
|
||||||
run(['roadmap', 'annotate-dependencies', '2.7'], tmp2);
|
|
||||||
const unpadded = fs.readFileSync(rp2, 'utf-8');
|
|
||||||
assert.equal(
|
|
||||||
after,
|
|
||||||
unpadded,
|
|
||||||
'annotate-dependencies must produce identical output for padded and un-padded ids'
|
|
||||||
);
|
|
||||||
// And the verb must not have silently destroyed the file (sanity).
|
|
||||||
assert.match(after, /### Phase 2\.7:/, 'phase header must survive');
|
|
||||||
// Reference `before` to keep it from being dead-binding-flagged
|
|
||||||
// and to assert the run did not corrupt the rest of the file.
|
|
||||||
assert.ok(before.length > 0);
|
|
||||||
} finally {
|
|
||||||
cleanup(tmp2);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
cleanup(tmp);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('roadmap update-plan-progress: control case — already wired in 1.42.1', () => {
|
|
||||||
// This is the one site already using phaseMarkdownRegexSource. Including
|
|
||||||
// it as a control proves the parity assertion is a meaningful signal
|
|
||||||
// (this test should pass on main, while the others fail).
|
|
||||||
const { aRoadmap, bRoadmap } = expectParity({
|
|
||||||
fixtureOpts: {},
|
|
||||||
verbWithPadded: (cwd) =>
|
|
||||||
run(['roadmap', 'update-plan-progress', '02.7'], cwd),
|
|
||||||
verbWithUnpadded: (cwd) =>
|
|
||||||
run(['roadmap', 'update-plan-progress', '2.7'], cwd),
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(
|
|
||||||
aRoadmap,
|
|
||||||
bRoadmap,
|
|
||||||
'control verb must already produce identical output (wired in 1.42.1)'
|
|
||||||
);
|
|
||||||
assert.match(
|
|
||||||
aRoadmap,
|
|
||||||
/- \[x\] \*\*Phase 2\.7:/,
|
|
||||||
'control verb must flip checkbox under both invocations'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,330 +0,0 @@
|
|||||||
/**
|
|
||||||
* Regression tests for bug #3584 — runtime emitters use the slash formatter.
|
|
||||||
*
|
|
||||||
* These tests exercise the actual runtime command handlers via `runGsdTools`
|
|
||||||
* and assert on the structured payloads they emit/persist. They prove that
|
|
||||||
* the high-impact emitters identified in the issue (`init.cjs` recommended
|
|
||||||
* actions, `phase.cjs` ROADMAP persistence, `verify.cjs` remediation hints,
|
|
||||||
* `milestone.cjs` Operator-Next-Steps persistence, `validate-command-router`
|
|
||||||
* fracture recommendations) no longer emit the unroutable `/gsd:<cmd>` colon
|
|
||||||
* form for skills-based runtimes.
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const {
|
|
||||||
runGsdTools,
|
|
||||||
createTempProject,
|
|
||||||
createTempDir,
|
|
||||||
cleanup,
|
|
||||||
} = require('./helpers.cjs');
|
|
||||||
|
|
||||||
// Helper: assert no string field anywhere in `value` (recursive) contains '/gsd:'.
|
|
||||||
function assertNoColonForm(value, label) {
|
|
||||||
const stack = [{ v: value, p: label }];
|
|
||||||
while (stack.length > 0) {
|
|
||||||
const { v, p } = stack.pop();
|
|
||||||
if (typeof v === 'string') {
|
|
||||||
assert.ok(
|
|
||||||
!v.includes('/gsd:'),
|
|
||||||
`${p}: must not contain deprecated /gsd: form, got ${JSON.stringify(v)}`,
|
|
||||||
);
|
|
||||||
} else if (Array.isArray(v)) {
|
|
||||||
v.forEach((item, i) => stack.push({ v: item, p: `${p}[${i}]` }));
|
|
||||||
} else if (v && typeof v === 'object') {
|
|
||||||
for (const key of Object.keys(v)) {
|
|
||||||
stack.push({ v: v[key], p: `${p}.${key}` });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper: collect every `command` string under a recommendedActions-style array.
|
|
||||||
function collectCommandFields(value) {
|
|
||||||
const out = [];
|
|
||||||
const stack = [value];
|
|
||||||
while (stack.length > 0) {
|
|
||||||
const v = stack.pop();
|
|
||||||
if (Array.isArray(v)) {
|
|
||||||
for (const item of v) stack.push(item);
|
|
||||||
} else if (v && typeof v === 'object') {
|
|
||||||
if (typeof v.command === 'string') out.push(v.command);
|
|
||||||
for (const key of Object.keys(v)) stack.push(v[key]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('bug-3584: init manager recommendedActions emit hyphen form', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = createTempProject();
|
|
||||||
// Minimal ROADMAP + STATE so init manager will run.
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
||||||
[
|
|
||||||
'# Roadmap',
|
|
||||||
'',
|
|
||||||
'## Milestone v1',
|
|
||||||
'',
|
|
||||||
'### Phase 1: Foundation',
|
|
||||||
'**Goal:** scaffold',
|
|
||||||
'**Plans:** 1 plan',
|
|
||||||
'',
|
|
||||||
].join('\n'),
|
|
||||||
);
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
|
||||||
'# State\n\nCurrent Phase: 1\n',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => cleanup(tmpDir));
|
|
||||||
|
|
||||||
test('skills runtime (claude) emits /gsd-<cmd> in recommended_actions[].command', () => {
|
|
||||||
// Plan-but-not-executed: directory exists with a PLAN.md → execute is recommended.
|
|
||||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
|
||||||
fs.mkdirSync(phaseDir, { recursive: true });
|
|
||||||
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
|
|
||||||
|
|
||||||
const result = runGsdTools('init manager', tmpDir, { GSD_RUNTIME: 'claude' });
|
|
||||||
assert.ok(result.success, `init manager failed: ${result.error || result.output}`);
|
|
||||||
|
|
||||||
const payload = JSON.parse(result.output);
|
|
||||||
const commands = collectCommandFields(payload.recommended_actions || []);
|
|
||||||
assert.ok(commands.length > 0, 'recommended_actions should produce at least one command');
|
|
||||||
|
|
||||||
for (const cmd of commands) {
|
|
||||||
assert.ok(
|
|
||||||
cmd.startsWith('/gsd-'),
|
|
||||||
`recommended_actions command must start with /gsd- for skills-based runtimes, got ${cmd}`,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
!cmd.includes('/gsd:'),
|
|
||||||
`recommended_actions command must not contain /gsd: colon form, got ${cmd}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
assertNoColonForm(payload, 'init.manager payload');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('codex runtime emits $gsd-<cmd> in recommended_actions[].command', () => {
|
|
||||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
|
||||||
fs.mkdirSync(phaseDir, { recursive: true });
|
|
||||||
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
|
|
||||||
|
|
||||||
const result = runGsdTools('init manager', tmpDir, { GSD_RUNTIME: 'codex' });
|
|
||||||
assert.ok(result.success, `init manager (codex) failed: ${result.error || result.output}`);
|
|
||||||
|
|
||||||
const payload = JSON.parse(result.output);
|
|
||||||
const commands = collectCommandFields(payload.recommended_actions || []);
|
|
||||||
assert.ok(commands.length > 0);
|
|
||||||
|
|
||||||
for (const cmd of commands) {
|
|
||||||
assert.ok(
|
|
||||||
cmd.startsWith('$gsd-'),
|
|
||||||
`codex recommended_actions command must use $gsd- shell-var form, got ${cmd}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('codex alias runtime emits $gsd-<cmd> in recommended_actions[].command', () => {
|
|
||||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
|
||||||
fs.mkdirSync(phaseDir, { recursive: true });
|
|
||||||
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
|
|
||||||
|
|
||||||
const result = runGsdTools('init manager', tmpDir, { GSD_RUNTIME: 'codex-app' });
|
|
||||||
assert.ok(result.success, `init manager (codex-app) failed: ${result.error || result.output}`);
|
|
||||||
|
|
||||||
const payload = JSON.parse(result.output);
|
|
||||||
const commands = collectCommandFields(payload.recommended_actions || []);
|
|
||||||
assert.ok(commands.length > 0);
|
|
||||||
|
|
||||||
for (const cmd of commands) {
|
|
||||||
assert.ok(
|
|
||||||
cmd.startsWith('$gsd-'),
|
|
||||||
`codex alias recommended_actions command must use $gsd- shell-var form, got ${cmd}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('bug-3584: phase add persists hyphen form into ROADMAP.md', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = createTempProject();
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
||||||
'# Roadmap\n\n## Milestone v1\n\n',
|
|
||||||
);
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
|
||||||
'# State\n',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => cleanup(tmpDir));
|
|
||||||
|
|
||||||
test('phase add persists hyphen-form slash command in roadmap get-phase payload', () => {
|
|
||||||
// 1. Add a phase — the system under test persists the phase entry into ROADMAP.md.
|
|
||||||
const addResult = runGsdTools(
|
|
||||||
['phase', 'add', 'Test new feature'],
|
|
||||||
tmpDir,
|
|
||||||
{ GSD_RUNTIME: 'claude' },
|
|
||||||
);
|
|
||||||
assert.ok(addResult.success, `phase add failed: ${addResult.error || addResult.output}`);
|
|
||||||
const addPayload = JSON.parse(addResult.output);
|
|
||||||
assert.ok(addPayload.phase_number, 'phase add must return phase_number');
|
|
||||||
|
|
||||||
// 2. Read the persisted section back via the structured roadmap-get-phase
|
|
||||||
// contract (NOT via readFileSync; the `section` field on the JSON payload
|
|
||||||
// is the runtime's typed projection of the on-disk ROADMAP content).
|
|
||||||
const getResult = runGsdTools(
|
|
||||||
['roadmap', 'get-phase', String(addPayload.phase_number)],
|
|
||||||
tmpDir,
|
|
||||||
{ GSD_RUNTIME: 'claude' },
|
|
||||||
);
|
|
||||||
assert.ok(getResult.success, `roadmap get-phase failed: ${getResult.error || getResult.output}`);
|
|
||||||
const getPayload = JSON.parse(getResult.output);
|
|
||||||
assert.ok(getPayload.found, 'roadmap get-phase must find the newly-added phase');
|
|
||||||
assert.ok(typeof getPayload.section === 'string', 'roadmap get-phase must return a section field');
|
|
||||||
|
|
||||||
// 3. The persisted phase section must use the routable hyphen form.
|
|
||||||
assert.ok(
|
|
||||||
!getPayload.section.includes('/gsd:plan-phase'),
|
|
||||||
`persisted phase section must not contain /gsd:plan-phase, got: ${getPayload.section}`,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
getPayload.section.includes('/gsd-plan-phase'),
|
|
||||||
`persisted phase section must contain /gsd-plan-phase, got: ${getPayload.section}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('bug-3584: validate health emits hyphen form in fix hints', () => {
|
|
||||||
test('validate health on a broken project returns hyphen-form fix strings', (t) => {
|
|
||||||
const tmpDir = createTempDir();
|
|
||||||
t.after(() => cleanup(tmpDir));
|
|
||||||
// No .planning directory → E001 fires with a `fix:` string that contains
|
|
||||||
// the slash-command form.
|
|
||||||
|
|
||||||
const result = runGsdTools(
|
|
||||||
['validate', 'health'],
|
|
||||||
tmpDir,
|
|
||||||
{ GSD_RUNTIME: 'claude' },
|
|
||||||
);
|
|
||||||
// validate health exits non-zero on broken projects but still emits JSON to stdout.
|
|
||||||
const stdout = result.output;
|
|
||||||
let payload;
|
|
||||||
try {
|
|
||||||
payload = JSON.parse(stdout);
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(`validate health did not emit parseable JSON: ${stdout.slice(0, 400)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const allIssues = []
|
|
||||||
.concat(payload.errors || [])
|
|
||||||
.concat(payload.warnings || [])
|
|
||||||
.concat(payload.info || []);
|
|
||||||
|
|
||||||
assert.ok(allIssues.length > 0, 'validate health on a bare tmpdir should report at least one issue');
|
|
||||||
|
|
||||||
const fixesWithSlash = allIssues
|
|
||||||
.map((i) => i.fix)
|
|
||||||
.filter((f) => typeof f === 'string' && /gsd[-:]/.test(f));
|
|
||||||
assert.ok(
|
|
||||||
fixesWithSlash.length > 0,
|
|
||||||
'at least one fix hint should reference a /gsd- slash command',
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const fix of fixesWithSlash) {
|
|
||||||
assert.ok(
|
|
||||||
!fix.includes('/gsd:'),
|
|
||||||
`validate health fix hint must not contain /gsd: colon form, got ${JSON.stringify(fix)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
assertNoColonForm(payload, 'validate health payload');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('bug-3584: validate context recommendation uses hyphen form', () => {
|
|
||||||
test('warning/critical recommendations emit /gsd-thread', (t) => {
|
|
||||||
const tmpDir = createTempProject();
|
|
||||||
t.after(() => cleanup(tmpDir));
|
|
||||||
|
|
||||||
// Critical band: 75% utilization. validate context emits JSON only with --json.
|
|
||||||
const result = runGsdTools(
|
|
||||||
['validate', 'context', '--tokens-used', '75000', '--context-window', '100000', '--json'],
|
|
||||||
tmpDir,
|
|
||||||
{ GSD_RUNTIME: 'claude' },
|
|
||||||
);
|
|
||||||
assert.ok(result.success, `validate context failed: ${result.error || result.output}`);
|
|
||||||
|
|
||||||
const payload = JSON.parse(result.output);
|
|
||||||
assert.ok(payload.recommendation, 'critical utilization should produce a recommendation');
|
|
||||||
assert.ok(
|
|
||||||
!payload.recommendation.includes('/gsd:'),
|
|
||||||
`recommendation must not contain /gsd:, got ${JSON.stringify(payload.recommendation)}`,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
payload.recommendation.includes('/gsd-thread'),
|
|
||||||
`recommendation must reference /gsd-thread, got ${JSON.stringify(payload.recommendation)}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('bug-3584: validate health uses formatter for codex runtime too', () => {
|
|
||||||
test('validate health under codex emits $gsd-<cmd> in fix strings (positive assertion)', (t) => {
|
|
||||||
const tmpDir = createTempDir();
|
|
||||||
t.after(() => cleanup(tmpDir));
|
|
||||||
|
|
||||||
const result = runGsdTools(
|
|
||||||
['validate', 'health'],
|
|
||||||
tmpDir,
|
|
||||||
{ GSD_RUNTIME: 'codex' },
|
|
||||||
);
|
|
||||||
let payload;
|
|
||||||
try {
|
|
||||||
payload = JSON.parse(result.output);
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(`validate health (codex) did not emit JSON: ${result.output.slice(0, 400)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const allIssues = []
|
|
||||||
.concat(payload.errors || [])
|
|
||||||
.concat(payload.warnings || [])
|
|
||||||
.concat(payload.info || []);
|
|
||||||
|
|
||||||
// Collect fixes that mention any gsd slash-command form so we can lock
|
|
||||||
// both the absence of legacy forms AND the presence of the codex shape.
|
|
||||||
const fixesWithGsdRef = allIssues
|
|
||||||
.map((i) => i.fix)
|
|
||||||
.filter((f) => typeof f === 'string' && /(?:\$|\/)gsd[-:]/.test(f));
|
|
||||||
|
|
||||||
assert.ok(
|
|
||||||
fixesWithGsdRef.length > 0,
|
|
||||||
'validate health on a bare tmpdir must produce at least one fix hint referencing a gsd command',
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const fix of fixesWithGsdRef) {
|
|
||||||
assert.ok(
|
|
||||||
fix.includes('$gsd-'),
|
|
||||||
`codex validate health fix must use shell-var $gsd- form, got ${JSON.stringify(fix)}`,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
!fix.includes('/gsd:'),
|
|
||||||
`codex validate health fix must not contain /gsd: colon form, got ${JSON.stringify(fix)}`,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
!fix.includes('/gsd-'),
|
|
||||||
`codex validate health fix must not contain /gsd- (skills) form, got ${JSON.stringify(fix)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
/**
|
|
||||||
* Bug #3599: roadmap.get-phase no longer matches custom phase IDs with
|
|
||||||
* project-code prefixes like `PROJ-42`.
|
|
||||||
*
|
|
||||||
* `phaseMarkdownRegexSource(phaseNum)` in gsd-core/bin/lib/core.cjs
|
|
||||||
* (and its SDK twin in sdk/src/query/roadmap-update-plan-progress.ts) strips
|
|
||||||
* the `PROJ-` prefix before building the padding-tolerant numeric regex.
|
|
||||||
* Result: `roadmap get-phase PROJ-42` produces a regex of `0*42`, which
|
|
||||||
* matches `### Phase 42:` instead of (or in addition to) the intended
|
|
||||||
* `### Phase PROJ-42:`. The function's own docstring promises a fallback to
|
|
||||||
* `escapeRegex(phaseNum)` for non-numeric custom IDs, but that branch is
|
|
||||||
* unreachable for project-code-prefixed numeric IDs.
|
|
||||||
*
|
|
||||||
* Fix: the emitted regex must match BOTH the stripped numeric form (so
|
|
||||||
* `CK-01-name` directory inputs still resolve to `Phase 1:` in prose, the
|
|
||||||
* #3537 contract) AND the full prefixed form (so `PROJ-42` resolves to
|
|
||||||
* `Phase PROJ-42:`).
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
|
||||||
|
|
||||||
function writeRoadmap(tmpDir, body) {
|
|
||||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), body);
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeState(tmpDir, version) {
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
|
||||||
`---\nmilestone: ${version}\n---\n`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('bug #3599: roadmap get-phase preserves project-code prefix in lookup', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = createTempProject('bug-3599-');
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('finds ### Phase PROJ-42: when queried as PROJ-42', () => {
|
|
||||||
writeState(tmpDir, 'v1.0.0');
|
|
||||||
writeRoadmap(
|
|
||||||
tmpDir,
|
|
||||||
[
|
|
||||||
'# Roadmap',
|
|
||||||
'',
|
|
||||||
'## Current Milestone: v1.0.0 - Test',
|
|
||||||
'',
|
|
||||||
'### Phase PROJ-42: Custom phase',
|
|
||||||
'**Goal:** Verify project-code-prefixed lookup',
|
|
||||||
'',
|
|
||||||
].join('\n'),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = runGsdTools('roadmap get-phase PROJ-42 --json', tmpDir);
|
|
||||||
assert.ok(result.success, `command failed: ${result.error || result.output}`);
|
|
||||||
|
|
||||||
const payload = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(payload.found, true, `expected found=true, got: ${result.output}`);
|
|
||||||
assert.strictEqual(payload.phase_name, 'Custom phase');
|
|
||||||
assert.strictEqual(payload.goal, 'Verify project-code-prefixed lookup');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('does NOT cross-match: querying 42 must not match ### Phase PROJ-42:', () => {
|
|
||||||
// Counter-test: if the regex erroneously matches both forms in both
|
|
||||||
// directions, this catches it. `42` must only match `Phase 42:` — not
|
|
||||||
// `Phase PROJ-42:` — otherwise integer phase lookups silently steal
|
|
||||||
// matches from prefixed siblings.
|
|
||||||
writeState(tmpDir, 'v1.0.0');
|
|
||||||
writeRoadmap(
|
|
||||||
tmpDir,
|
|
||||||
[
|
|
||||||
'# Roadmap',
|
|
||||||
'',
|
|
||||||
'## Current Milestone: v1.0.0 - Test',
|
|
||||||
'',
|
|
||||||
'### Phase PROJ-42: Should not be returned for `42`',
|
|
||||||
'**Goal:** Counter-test',
|
|
||||||
'',
|
|
||||||
].join('\n'),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = runGsdTools('roadmap get-phase 42 --json', tmpDir);
|
|
||||||
assert.ok(result.success);
|
|
||||||
const payload = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(
|
|
||||||
payload.found,
|
|
||||||
false,
|
|
||||||
`bare numeric '42' must not match 'Phase PROJ-42:'; got ${result.output}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('preserves #3537 contract: CK-01 directory form resolves to Phase 1 prose', () => {
|
|
||||||
// Existing contract: phase directory names like `CK-01-name` carry the
|
|
||||||
// project_code prefix and a zero-padded number, but ROADMAP prose is
|
|
||||||
// typically un-padded (`### Phase 1:`). The padding-tolerant lookup must
|
|
||||||
// still bridge those two surfaces.
|
|
||||||
writeState(tmpDir, 'v1.0.0');
|
|
||||||
writeRoadmap(
|
|
||||||
tmpDir,
|
|
||||||
[
|
|
||||||
'# Roadmap',
|
|
||||||
'',
|
|
||||||
'## Current Milestone: v1.0.0 - Test',
|
|
||||||
'',
|
|
||||||
'### Phase 1: Numeric prose',
|
|
||||||
'**Goal:** #3537 contract — CK-01 dir → Phase 1 prose',
|
|
||||||
'',
|
|
||||||
].join('\n'),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = runGsdTools('roadmap get-phase CK-01 --json', tmpDir);
|
|
||||||
assert.ok(result.success);
|
|
||||||
const payload = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(
|
|
||||||
payload.found,
|
|
||||||
true,
|
|
||||||
`CK-01 must still resolve to 'Phase 1:' prose (#3537 contract); got ${result.output}`,
|
|
||||||
);
|
|
||||||
assert.strictEqual(payload.phase_name, 'Numeric prose');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('finds the right phase when both prefixed and bare forms coexist', () => {
|
|
||||||
// Disambiguation test: a roadmap that contains BOTH `### Phase 42:` and
|
|
||||||
// `### Phase PROJ-42:` must resolve each query to its specific match.
|
|
||||||
writeState(tmpDir, 'v1.0.0');
|
|
||||||
writeRoadmap(
|
|
||||||
tmpDir,
|
|
||||||
[
|
|
||||||
'# Roadmap',
|
|
||||||
'',
|
|
||||||
'## Current Milestone: v1.0.0 - Test',
|
|
||||||
'',
|
|
||||||
'### Phase 42: Bare numeric',
|
|
||||||
'**Goal:** Bare',
|
|
||||||
'',
|
|
||||||
'### Phase PROJ-42: Prefixed',
|
|
||||||
'**Goal:** Prefixed',
|
|
||||||
'',
|
|
||||||
].join('\n'),
|
|
||||||
);
|
|
||||||
|
|
||||||
const r42 = runGsdTools('roadmap get-phase 42 --json', tmpDir);
|
|
||||||
const rProj = runGsdTools('roadmap get-phase PROJ-42 --json', tmpDir);
|
|
||||||
|
|
||||||
const p42 = JSON.parse(r42.output);
|
|
||||||
const pProj = JSON.parse(rProj.output);
|
|
||||||
|
|
||||||
assert.strictEqual(p42.found, true);
|
|
||||||
assert.strictEqual(p42.phase_name, 'Bare numeric');
|
|
||||||
assert.strictEqual(p42.goal, 'Bare');
|
|
||||||
|
|
||||||
assert.strictEqual(pProj.found, true);
|
|
||||||
assert.strictEqual(pProj.phase_name, 'Prefixed');
|
|
||||||
assert.strictEqual(pProj.goal, 'Prefixed');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Regression tests for #3631 — SDK dispatch path in family routers must
|
|
||||||
* forward the `--raw` flag through to `output()`.
|
|
||||||
*
|
|
||||||
* Before the fix, every `*-command-router.cjs` `sdkHandler` called
|
|
||||||
* `output(result.data)` without the second positional `raw` argument or the
|
|
||||||
* third positional `rawValue`. With `--raw` set, the SDK path therefore
|
|
||||||
* emitted JSON-stringified data ({"next":"2.1",...}) instead of the scalar
|
|
||||||
* the CJS path used to print (e.g. `2.1`).
|
|
||||||
*
|
|
||||||
* Both tests below exercise the live SDK path:
|
|
||||||
* 1. `phase next-decimal --raw <base>` must emit the next-decimal token.
|
|
||||||
* 2. `roadmap get-phase --raw <id>` must emit the phase's roadmap section.
|
|
||||||
*
|
|
||||||
* Per CONTRIBUTING.md: assertions are on structured (scalar) tokens, not
|
|
||||||
* substring grep against full JSON.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { test, describe } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
const os = require('node:os');
|
|
||||||
const { execFileSync } = require('node:child_process');
|
|
||||||
const { cleanup } = require('./helpers.cjs');
|
|
||||||
|
|
||||||
const GSD_TOOLS = path.resolve(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
|
||||||
|
|
||||||
function run(args, cwd) {
|
|
||||||
try {
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
stdout: execFileSync(process.execPath, [GSD_TOOLS, ...args], {
|
|
||||||
cwd,
|
|
||||||
encoding: 'utf-8',
|
|
||||||
timeout: 15000,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
stdout: (e.stdout && e.stdout.toString()) || '',
|
|
||||||
stderr: (e.stderr && e.stderr.toString()) || '',
|
|
||||||
code: e.status,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeFixture() {
|
|
||||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3631-'));
|
|
||||||
const planning = path.join(tmp, '.planning');
|
|
||||||
fs.mkdirSync(path.join(planning, 'phases'), { recursive: true });
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(planning, 'ROADMAP.md'),
|
|
||||||
[
|
|
||||||
'# Project Roadmap',
|
|
||||||
'',
|
|
||||||
'## v1',
|
|
||||||
'',
|
|
||||||
'### Phase 1: First',
|
|
||||||
'',
|
|
||||||
'Body of phase 1.',
|
|
||||||
'',
|
|
||||||
'### Phase 2: Second',
|
|
||||||
'',
|
|
||||||
'Body of phase 2.',
|
|
||||||
'',
|
|
||||||
].join('\n')
|
|
||||||
);
|
|
||||||
// PROJECT.md anchors the planning root for callers that resolve it.
|
|
||||||
fs.writeFileSync(path.join(planning, 'PROJECT.md'), '# Test\n');
|
|
||||||
return tmp;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('bug #3631 — SDK family routers forward --raw to output()', () => {
|
|
||||||
test('phase next-decimal --raw emits the scalar next-decimal token (not JSON)', () => {
|
|
||||||
const tmp = makeFixture();
|
|
||||||
try {
|
|
||||||
const res = run(['phase', 'next-decimal', '--raw', '1'], tmp);
|
|
||||||
assert.ok(
|
|
||||||
res.ok,
|
|
||||||
`command must succeed; got code=${res.code} stderr=${res.stderr}`
|
|
||||||
);
|
|
||||||
const trimmed = res.stdout.trim();
|
|
||||||
// Scalar form — must be a phase id token like "1.1", not a JSON object.
|
|
||||||
assert.doesNotMatch(
|
|
||||||
trimmed,
|
|
||||||
/^\{/,
|
|
||||||
`--raw must not emit JSON; got: ${trimmed}`
|
|
||||||
);
|
|
||||||
assert.match(
|
|
||||||
trimmed,
|
|
||||||
/^0*\d+(?:\.\d+)?$/,
|
|
||||||
`--raw must emit a scalar phase id; got: ${trimmed}`
|
|
||||||
);
|
|
||||||
// SDK and CJS both normalize the base phase before computing the next-
|
|
||||||
// decimal token; CJS emits "1.1" while SDK normalizes "1"→"01" and emits
|
|
||||||
// "01.1". Both are valid scalar projections — assert on parity with the
|
|
||||||
// computed-next semantics rather than the exact padding form.
|
|
||||||
assert.ok(
|
|
||||||
trimmed === '1.1' || trimmed === '01.1',
|
|
||||||
`expected next-decimal of base "1" to be 1.1 or 01.1; got: ${trimmed}`
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
cleanup(tmp);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('roadmap get-phase --raw emits the phase section (not JSON)', () => {
|
|
||||||
const tmp = makeFixture();
|
|
||||||
try {
|
|
||||||
const res = run(['roadmap', 'get-phase', '--raw', '2'], tmp);
|
|
||||||
assert.ok(
|
|
||||||
res.ok,
|
|
||||||
`command must succeed; got code=${res.code} stderr=${res.stderr}`
|
|
||||||
);
|
|
||||||
const trimmed = res.stdout.trim();
|
|
||||||
assert.doesNotMatch(
|
|
||||||
trimmed,
|
|
||||||
/^\{/,
|
|
||||||
`--raw must not emit JSON; got: ${trimmed.slice(0, 80)}`
|
|
||||||
);
|
|
||||||
// Section text starts with the heading.
|
|
||||||
assert.match(
|
|
||||||
trimmed,
|
|
||||||
/Phase 2:\s*Second/,
|
|
||||||
`--raw must emit the section body containing the Phase 2 heading; got: ${trimmed.slice(0, 80)}`
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
cleanup(tmp);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,507 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
// allow-test-rule: source-text-is-the-product
|
|
||||||
// Reads .md/.json/.yml product files whose deployed text IS what the
|
|
||||||
// runtime loads — testing text content tests the deployed contract.
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Regression — issue #3691
|
|
||||||
*
|
|
||||||
* Two regex defects fixed in `roadmap.cjs` function `cmdRoadmapAnnotateDependencies`,
|
|
||||||
* plus two review-cycle additions (F3 defensive guard, F4 adversarial gaps):
|
|
||||||
*
|
|
||||||
* Bug 1 (line ~553) — Plans-block detection regex `/(Plans:\s*\n)/i` requires no text
|
|
||||||
* after the colon. Headers like `Plans: 3 plans across 2 waves\n` or
|
|
||||||
* `**Plans:** 3 plans\n` are silently skipped and the function early-returns.
|
|
||||||
* Fix: `(?:^|\n)(\*{0,2}Plans\*{0,2}:[^\n]*\n)` anchors to start-of-line and
|
|
||||||
* accepts optional bold wrappers and any trailing text on the header line.
|
|
||||||
*
|
|
||||||
* Bug 3 (line ~566) — Plan-ID extraction regex `/([\w-]+?)/` excludes `.`, so
|
|
||||||
* decimal plan IDs like `02.3-01` are captured as `02` only, never match
|
|
||||||
* the planData entry, and every plan defaults to wave 1.
|
|
||||||
* Fix: `[\w.-]+?` includes `.` so decimal IDs are captured in full.
|
|
||||||
*
|
|
||||||
* Note: "Bug 2" (phase-section boundary `\d` → `\d[\d.]*`) was confirmed empirically
|
|
||||||
* to be a no-op: any phase heading starts with a digit, so `\d` already matches.
|
|
||||||
* The no-op change was dropped from the PR; this file has no Bug 2 describe block.
|
|
||||||
*
|
|
||||||
* Review additions:
|
|
||||||
* F3 — Leading-dot plan ID guard: malformed IDs like `.invalid` are rejected
|
|
||||||
* before planData.find() rather than silently defaulting to wave 1.
|
|
||||||
* F4 — Adversarial gaps: multi-decimal leading-zero IDs (001.10-PLAN.md) and
|
|
||||||
* bare-bold `**Plans:**` (no trailing text) are explicitly covered.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { test, describe, afterEach } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Shared test helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function makePlanProject(files = {}) {
|
|
||||||
const dir = createTempProject();
|
|
||||||
fs.mkdirSync(path.join(dir, '.planning', 'phases'), { recursive: true });
|
|
||||||
for (const [rel, content] of Object.entries(files)) {
|
|
||||||
const abs = path.join(dir, rel);
|
|
||||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
||||||
fs.writeFileSync(abs, content, 'utf-8');
|
|
||||||
}
|
|
||||||
return dir;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build a minimal PLAN.md frontmatter string */
|
|
||||||
function makePlan({ phase, plan, wave, dependsOn = [] }) {
|
|
||||||
return [
|
|
||||||
'---',
|
|
||||||
`phase: "${phase}"`,
|
|
||||||
`plan: "${plan}"`,
|
|
||||||
'type: standard',
|
|
||||||
`wave: ${wave}`,
|
|
||||||
`depends_on: [${dependsOn.map(d => `"${d}"`).join(', ')}]`,
|
|
||||||
'files_modified: []',
|
|
||||||
'autonomous: true',
|
|
||||||
'must_haves:',
|
|
||||||
' truths: []',
|
|
||||||
' artifacts: []',
|
|
||||||
' key_links: []',
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
`<objective>Plan ${plan}</objective>`,
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Bug 1 — Plans-block detection: inline summary text after the colon
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
describe('bug #3691 — Bug 1: Plans-block detection with inline summary', () => {
|
|
||||||
let tmpDir;
|
|
||||||
afterEach(() => cleanup(tmpDir));
|
|
||||||
|
|
||||||
test('Plans: N plans (inline count after colon) is detected as a Plans-block', (_t) => {
|
|
||||||
// Pre-fix: `Plans:\s*\n` requires bare newline — fails for "Plans: 2 plans\n"
|
|
||||||
// Post-fix: `Plans:[^\n]*\n` accepts any text after the colon
|
|
||||||
const roadmap = [
|
|
||||||
'# Roadmap',
|
|
||||||
'',
|
|
||||||
'### Phase 1: Foundation',
|
|
||||||
'',
|
|
||||||
'**Goal:** Set up project',
|
|
||||||
'',
|
|
||||||
'Plans: 2 plans',
|
|
||||||
'- [ ] 01-01-PLAN.md — Task A',
|
|
||||||
'- [ ] 01-02-PLAN.md — Task B',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
tmpDir = makePlanProject({
|
|
||||||
'.planning/ROADMAP.md': roadmap,
|
|
||||||
'.planning/phases/01-foundation/01-01-PLAN.md': makePlan({ phase: '1', plan: '01-01', wave: 1 }),
|
|
||||||
'.planning/phases/01-foundation/01-02-PLAN.md': makePlan({ phase: '1', plan: '01-02', wave: 2, dependsOn: ['01-01'] }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
||||||
|
|
||||||
const out = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(out.updated, true,
|
|
||||||
'Plans-block with inline summary must be detected and written');
|
|
||||||
assert.ok(out.waves >= 1, 'at least one wave must be written');
|
|
||||||
|
|
||||||
const written = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
|
||||||
assert.ok(written.includes('Wave'), 'wave annotation must appear in ROADMAP.md');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Plans: N plans across N waves (longer inline text) is detected', (_t) => {
|
|
||||||
const roadmap = [
|
|
||||||
'# Roadmap',
|
|
||||||
'',
|
|
||||||
'### Phase 1: Foundation',
|
|
||||||
'',
|
|
||||||
'Plans: 3 plans across 2 waves',
|
|
||||||
'- [ ] 01-01-PLAN.md — Task A',
|
|
||||||
'- [ ] 01-02-PLAN.md — Task B',
|
|
||||||
'- [ ] 01-03-PLAN.md — Task C',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
tmpDir = makePlanProject({
|
|
||||||
'.planning/ROADMAP.md': roadmap,
|
|
||||||
'.planning/phases/01-foundation/01-01-PLAN.md': makePlan({ phase: '1', plan: '01-01', wave: 1 }),
|
|
||||||
'.planning/phases/01-foundation/01-02-PLAN.md': makePlan({ phase: '1', plan: '01-02', wave: 1 }),
|
|
||||||
'.planning/phases/01-foundation/01-03-PLAN.md': makePlan({ phase: '1', plan: '01-03', wave: 2, dependsOn: ['01-01'] }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
||||||
const out = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(out.updated, true,
|
|
||||||
'Plans-block with "N plans across N waves" inline text must be detected');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('**Plans:** (bold markdown wrapper) is detected as a Plans-block', (_t) => {
|
|
||||||
// Bold wrapper: `**Plans:** 3 plans across 2 waves`
|
|
||||||
const roadmap = [
|
|
||||||
'# Roadmap',
|
|
||||||
'',
|
|
||||||
'### Phase 1: Foundation',
|
|
||||||
'',
|
|
||||||
'**Plans:** 2 plans',
|
|
||||||
'- [ ] 01-01-PLAN.md — Task A',
|
|
||||||
'- [ ] 01-02-PLAN.md — Task B',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
tmpDir = makePlanProject({
|
|
||||||
'.planning/ROADMAP.md': roadmap,
|
|
||||||
'.planning/phases/01-foundation/01-01-PLAN.md': makePlan({ phase: '1', plan: '01-01', wave: 1 }),
|
|
||||||
'.planning/phases/01-foundation/01-02-PLAN.md': makePlan({ phase: '1', plan: '01-02', wave: 2 }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
||||||
const out = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(out.updated, true,
|
|
||||||
'**Plans:** bold-wrapped header must be detected as a Plans-block');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('bare Plans: (no inline text, legacy format) still works after fix', (_t) => {
|
|
||||||
// Regression guard: the fix must not break the working case
|
|
||||||
const roadmap = [
|
|
||||||
'# Roadmap',
|
|
||||||
'',
|
|
||||||
'### Phase 1: Foundation',
|
|
||||||
'',
|
|
||||||
'Plans:',
|
|
||||||
'- [ ] 01-01-PLAN.md — Task A',
|
|
||||||
'- [ ] 01-02-PLAN.md — Task B',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
tmpDir = makePlanProject({
|
|
||||||
'.planning/ROADMAP.md': roadmap,
|
|
||||||
'.planning/phases/01-foundation/01-01-PLAN.md': makePlan({ phase: '1', plan: '01-01', wave: 1 }),
|
|
||||||
'.planning/phases/01-foundation/01-02-PLAN.md': makePlan({ phase: '1', plan: '01-02', wave: 2 }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
||||||
const out = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(out.updated, true,
|
|
||||||
'bare Plans: (legacy format) must still be detected after the fix');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Bug 3 — Plan-ID extraction: decimal phase IDs like 02.3-01
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
describe('bug #3691 — Bug 3: decimal plan IDs (e.g. 02.3-01-PLAN.md) parse correctly', () => {
|
|
||||||
let tmpDir;
|
|
||||||
afterEach(() => cleanup(tmpDir));
|
|
||||||
|
|
||||||
test('decimal plan ID 02.3-01 is captured fully and matched to the correct wave', (_t) => {
|
|
||||||
// Pre-fix: `[\w-]+?` stops at `.` → captures `02` only → planData.find misses → wave = 1 for all
|
|
||||||
// Post-fix: `[\w.-]+?` captures `02.3-01` → planData.find resolves → correct wave written
|
|
||||||
const roadmap = [
|
|
||||||
'# Roadmap',
|
|
||||||
'',
|
|
||||||
'### Phase 02.3: Surgical edit ops',
|
|
||||||
'',
|
|
||||||
'Plans: 2 plans across 2 waves',
|
|
||||||
'- [ ] 02.3-01-PLAN.md — Path resolver',
|
|
||||||
'- [ ] 02.3-02-PLAN.md — Op handlers',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
tmpDir = makePlanProject({
|
|
||||||
'.planning/ROADMAP.md': roadmap,
|
|
||||||
'.planning/phases/02.3-surgical-edit-ops/02.3-01-PLAN.md': makePlan({ phase: '02.3', plan: '02.3-01', wave: 1 }),
|
|
||||||
'.planning/phases/02.3-surgical-edit-ops/02.3-02-PLAN.md': makePlan({ phase: '02.3', plan: '02.3-02', wave: 2, dependsOn: ['02.3-01'] }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = runGsdTools('roadmap annotate-dependencies 02.3', tmpDir);
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
||||||
const out = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(out.updated, true,
|
|
||||||
'decimal-phase ROADMAP with inline Plans: summary must be annotated');
|
|
||||||
assert.strictEqual(out.waves, 2,
|
|
||||||
'two distinct waves must be identified (02.3-01→wave 1, 02.3-02→wave 2)');
|
|
||||||
|
|
||||||
const written = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
|
||||||
assert.ok(/Wave 1/.test(written), 'Wave 1 header must appear in output');
|
|
||||||
assert.ok(/Wave 2/.test(written), 'Wave 2 header must appear in output');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('combined fixture: decimal phase + bold Plans: header (both bugs together)', (_t) => {
|
|
||||||
// Exercises Bug 1 (bold **Plans:** header) AND Bug 3 (decimal IDs) simultaneously.
|
|
||||||
// This is the exact ROADMAP fragment from the issue report.
|
|
||||||
const roadmap = [
|
|
||||||
'# Roadmap',
|
|
||||||
'',
|
|
||||||
'## Milestone v1.2',
|
|
||||||
'',
|
|
||||||
'### Phase 02.3: Surgical edit ops',
|
|
||||||
'',
|
|
||||||
'**Plans:** 3 plans across 2 waves',
|
|
||||||
'- [ ] 02.3-01-PLAN.md — Path resolver',
|
|
||||||
'- [ ] 02.3-02-PLAN.md — Op handlers',
|
|
||||||
'- [ ] 02.3-03-PLAN.md — Tests',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
tmpDir = makePlanProject({
|
|
||||||
'.planning/ROADMAP.md': roadmap,
|
|
||||||
'.planning/phases/02.3-surgical-edit-ops/02.3-01-PLAN.md': makePlan({ phase: '02.3', plan: '02.3-01', wave: 1 }),
|
|
||||||
'.planning/phases/02.3-surgical-edit-ops/02.3-02-PLAN.md': makePlan({ phase: '02.3', plan: '02.3-02', wave: 1 }),
|
|
||||||
'.planning/phases/02.3-surgical-edit-ops/02.3-03-PLAN.md': makePlan({ phase: '02.3', plan: '02.3-03', wave: 2, dependsOn: ['02.3-01', '02.3-02'] }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = runGsdTools('roadmap annotate-dependencies 02.3', tmpDir);
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
||||||
const out = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(out.updated, true,
|
|
||||||
'combined fixture (bold Plans: + decimal IDs) must produce updated: true');
|
|
||||||
assert.strictEqual(out.waves, 2,
|
|
||||||
'wave 2 dependency must be detected from decimal plan IDs');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// F3 review fix — Leading-dot ID validation guard (defensive, malformed ROADMAP)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
describe('review fix F3 — leading-dot plan ID is rejected (defensive guard)', () => {
|
|
||||||
let tmpDir;
|
|
||||||
afterEach(() => cleanup(tmpDir));
|
|
||||||
|
|
||||||
test('checklist line with leading-dot plan ID is skipped and does not silently default to wave 1', (_t) => {
|
|
||||||
// Guards: `.invalid-PLAN.md` would be captured as `.invalid` by the `[\w.-]+?` regex
|
|
||||||
// (since `.` is now included), which starts with a dot — an invalid ID.
|
|
||||||
// Without the guard, planData.find() misses it and wave defaults to 1, silently
|
|
||||||
// polluting the output. With the guard, the line is skipped entirely.
|
|
||||||
// We verify this by having TWO real plans with known waves (1 and 2), plus one
|
|
||||||
// malformed line. The malformed line must not appear in the wave-annotated output.
|
|
||||||
const roadmap = [
|
|
||||||
'# Roadmap',
|
|
||||||
'',
|
|
||||||
'### Phase 1: Foundation',
|
|
||||||
'',
|
|
||||||
'Plans: 3 items',
|
|
||||||
'- [ ] 01-01-PLAN.md — Task A',
|
|
||||||
'- [ ] .invalid-PLAN.md — Corrupted entry',
|
|
||||||
'- [ ] 01-02-PLAN.md — Task B',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
tmpDir = makePlanProject({
|
|
||||||
'.planning/ROADMAP.md': roadmap,
|
|
||||||
'.planning/phases/01-foundation/01-01-PLAN.md': makePlan({ phase: '1', plan: '01-01', wave: 1 }),
|
|
||||||
'.planning/phases/01-foundation/01-02-PLAN.md': makePlan({ phase: '1', plan: '01-02', wave: 2, dependsOn: ['01-01'] }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
||||||
const out = JSON.parse(result.output);
|
|
||||||
// Two valid plans resolve to 2 waves — annotation must still proceed
|
|
||||||
assert.strictEqual(out.updated, true, 'annotation must proceed despite malformed line');
|
|
||||||
assert.strictEqual(out.waves, 2, 'two distinct waves from the two valid plans');
|
|
||||||
|
|
||||||
const written = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
|
||||||
// The malformed line should not appear in the written output (it was skipped)
|
|
||||||
assert.ok(!written.includes('.invalid-PLAN.md'),
|
|
||||||
'malformed leading-dot entry must be dropped from the annotated output');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// F4 review additions — adversarial test gaps
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
describe('review fix F4 — adversarial test gaps', () => {
|
|
||||||
let tmpDir;
|
|
||||||
afterEach(() => cleanup(tmpDir));
|
|
||||||
|
|
||||||
test('001.10-PLAN.md multi-decimal leading-zero ID is captured fully and wave-assigned correctly', (_t) => {
|
|
||||||
// Guards regression of Bug 3: `[\w-]+?` would stop at the first `.` and
|
|
||||||
// capture `001` instead of `001.10`, which never matches any planData entry.
|
|
||||||
// Post-fix `[\w.-]+?` must capture `001.10` in full.
|
|
||||||
const roadmap = [
|
|
||||||
'# Roadmap',
|
|
||||||
'',
|
|
||||||
'### Phase 001.10: Extended decimal phase',
|
|
||||||
'',
|
|
||||||
'Plans: 2 plans across 2 waves',
|
|
||||||
'- [ ] 001.10-01-PLAN.md — First task',
|
|
||||||
'- [ ] 001.10-02-PLAN.md — Second task',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
tmpDir = makePlanProject({
|
|
||||||
'.planning/ROADMAP.md': roadmap,
|
|
||||||
'.planning/phases/001.10-extended/001.10-01-PLAN.md': makePlan({ phase: '001.10', plan: '001.10-01', wave: 1 }),
|
|
||||||
'.planning/phases/001.10-extended/001.10-02-PLAN.md': makePlan({ phase: '001.10', plan: '001.10-02', wave: 2, dependsOn: ['001.10-01'] }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = runGsdTools('roadmap annotate-dependencies 001.10', tmpDir);
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
||||||
const out = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(out.updated, true,
|
|
||||||
'multi-decimal leading-zero plan ID must be captured fully and annotated');
|
|
||||||
assert.strictEqual(out.waves, 2,
|
|
||||||
'wave 2 dependency must be resolved from full 001.10-02 ID (not truncated to 001)');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('**Plans:** (bold, no trailing text) is matched and checklist is processed', (_t) => {
|
|
||||||
// Guards the bare-bold variant: `**Plans:**` with nothing after the colon.
|
|
||||||
// The `[^\n]*` quantifier accepts zero chars so this should already work,
|
|
||||||
// but this test would fail if `\*{0,2}Plans\*{0,2}` regressed to require no stars.
|
|
||||||
const roadmap = [
|
|
||||||
'# Roadmap',
|
|
||||||
'',
|
|
||||||
'### Phase 1: Foundation',
|
|
||||||
'',
|
|
||||||
'**Plans:**',
|
|
||||||
'- [ ] 01-01-PLAN.md — Task A',
|
|
||||||
'- [ ] 01-02-PLAN.md — Task B',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
tmpDir = makePlanProject({
|
|
||||||
'.planning/ROADMAP.md': roadmap,
|
|
||||||
'.planning/phases/01-foundation/01-01-PLAN.md': makePlan({ phase: '1', plan: '01-01', wave: 1 }),
|
|
||||||
'.planning/phases/01-foundation/01-02-PLAN.md': makePlan({ phase: '1', plan: '01-02', wave: 2, dependsOn: ['01-01'] }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
||||||
const out = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(out.updated, true,
|
|
||||||
'**Plans:** bare-bold variant (no trailing text) must be detected and annotated');
|
|
||||||
assert.strictEqual(out.waves, 2,
|
|
||||||
'wave assignment must resolve correctly from planData for bare-bold variant');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Bug #1103 — leading newline dropped in replace, fusing adjacent lines
|
|
||||||
//
|
|
||||||
// On the SUCCESSFUL mutation path, the block matcher anchors with `(?:^|\n)`.
|
|
||||||
// On a mid-string match (a `**Plans:** N plans` bold summary line directly above
|
|
||||||
// a bare `Plans:` block) `plansBlockMatch[0]` begins with the consumed `\n`. The
|
|
||||||
// replacement did not re-emit it, fusing the summary line onto the header →
|
|
||||||
// `**Plans:** 3 plansPlans:`. Distinct from #3691's silent-no-op detection bugs;
|
|
||||||
// #3691's Bug 1 fix is what let this two-line layout be matched and exposed it.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
describe('bug #1103 — annotate-dependencies preserves newline before Plans: header', () => {
|
|
||||||
let tmpDir;
|
|
||||||
afterEach(() => cleanup(tmpDir));
|
|
||||||
|
|
||||||
test('bold **Plans:** summary line followed by bare Plans: header are not fused', (_t) => {
|
|
||||||
const roadmap = [
|
|
||||||
'# Roadmap',
|
|
||||||
'',
|
|
||||||
'### Phase 1: Foundation',
|
|
||||||
'',
|
|
||||||
'**Goal:** Set up project',
|
|
||||||
'',
|
|
||||||
'**Plans:** 3 plans',
|
|
||||||
'Plans:',
|
|
||||||
'- [ ] 01-01-PLAN.md — Task A',
|
|
||||||
'- [ ] 01-02-PLAN.md — Task B',
|
|
||||||
'- [ ] 01-03-PLAN.md — Task C',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
tmpDir = makePlanProject({
|
|
||||||
'.planning/ROADMAP.md': roadmap,
|
|
||||||
'.planning/phases/01-foundation/01-01-PLAN.md': makePlan({ phase: '1', plan: '01-01', wave: 1 }),
|
|
||||||
'.planning/phases/01-foundation/01-02-PLAN.md': makePlan({ phase: '1', plan: '01-02', wave: 1 }),
|
|
||||||
'.planning/phases/01-foundation/01-03-PLAN.md': makePlan({ phase: '1', plan: '01-03', wave: 2, dependsOn: ['01-01'] }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
||||||
const out = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(out.updated, true, 'annotation must succeed and write back');
|
|
||||||
|
|
||||||
const written = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
|
||||||
|
|
||||||
// Core assertion: the malformed fusion string must NOT appear.
|
|
||||||
assert.ok(
|
|
||||||
!written.includes('plansPlans:'),
|
|
||||||
`ROADMAP must not contain the fused string "plansPlans:"; got:\n${written}`
|
|
||||||
);
|
|
||||||
// The bold summary line must keep its line boundary before Plans:.
|
|
||||||
assert.ok(
|
|
||||||
written.includes('**Plans:** 3 plans\nPlans:'),
|
|
||||||
`**Plans:** 3 plans must be followed by a newline before Plans:; got:\n${written}`
|
|
||||||
);
|
|
||||||
assert.ok(written.includes('Wave'), 'wave annotation must appear in ROADMAP.md');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('inline Plans: header (no bold summary prefix) still annotates after fix', (_t) => {
|
|
||||||
const roadmap = [
|
|
||||||
'# Roadmap',
|
|
||||||
'',
|
|
||||||
'### Phase 1: Foundation',
|
|
||||||
'',
|
|
||||||
'Plans: 2 plans',
|
|
||||||
'- [ ] 01-01-PLAN.md — Task A',
|
|
||||||
'- [ ] 01-02-PLAN.md — Task B',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
tmpDir = makePlanProject({
|
|
||||||
'.planning/ROADMAP.md': roadmap,
|
|
||||||
'.planning/phases/01-foundation/01-01-PLAN.md': makePlan({ phase: '1', plan: '01-01', wave: 1 }),
|
|
||||||
'.planning/phases/01-foundation/01-02-PLAN.md': makePlan({ phase: '1', plan: '01-02', wave: 2, dependsOn: ['01-01'] }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
||||||
const out = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(out.updated, true, 'inline Plans: header must still be annotated after fix');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('mid-string Plans: header keeps exact heading→header spacing (no extra newline)', (_t) => {
|
|
||||||
// `phaseSection` always begins with the `### Phase` heading, so the `Plans:`
|
|
||||||
// match is always mid-string and `(?:^|\n)` matches a `\n` (the start-of-string
|
|
||||||
// `^` branch is unreachable through the handler). This guards the inverse of the
|
|
||||||
// bug: the re-emitted newline must restore EXACTLY the one `\n` the regex
|
|
||||||
// consumed — not a doubled blank line and not a fusion.
|
|
||||||
const roadmap = [
|
|
||||||
'### Phase 1: Foundation',
|
|
||||||
'',
|
|
||||||
'Plans:',
|
|
||||||
'- [ ] 01-01-PLAN.md — Task A',
|
|
||||||
'- [ ] 01-02-PLAN.md — Task B',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
tmpDir = makePlanProject({
|
|
||||||
'.planning/ROADMAP.md': roadmap,
|
|
||||||
'.planning/phases/01-foundation/01-01-PLAN.md': makePlan({ phase: '1', plan: '01-01', wave: 1 }),
|
|
||||||
'.planning/phases/01-foundation/01-02-PLAN.md': makePlan({ phase: '1', plan: '01-02', wave: 2, dependsOn: ['01-01'] }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
||||||
const out = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(out.updated, true, 'Plans: must still be detected and annotated');
|
|
||||||
|
|
||||||
const written = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
|
||||||
assert.ok(
|
|
||||||
written.includes('### Phase 1: Foundation\n\nPlans:'),
|
|
||||||
`heading→Plans spacing must be preserved exactly; got:\n${written}`
|
|
||||||
);
|
|
||||||
assert.ok(!written.includes('FoundationPlans:'), 'heading must not fuse onto Plans:');
|
|
||||||
assert.ok(!written.includes('Foundation\n\n\nPlans:'), 'no doubled blank line before Plans:');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,376 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
// allow-test-rule: reads runtime STATE.md written to temp dir — behavioral output test, not source-grep
|
|
||||||
|
|
||||||
// Regression tests for bug #397.
|
|
||||||
//
|
|
||||||
// STATE.md fields edited by an executor (e.g. a hand-authored Resume File path,
|
|
||||||
// a custom Status value, or a custom Last Activity entry) were silently overwritten
|
|
||||||
// by the next call to record-session, advance-plan, or planned-phase because the
|
|
||||||
// handlers used unconditional stateReplaceField / stateReplaceFieldWithFallback
|
|
||||||
// calls, even when the option was not passed by the caller.
|
|
||||||
//
|
|
||||||
// Fix: introduce KNOWN_TEMPLATE_DEFAULTS (a per-field table of string values that
|
|
||||||
// are safe to replace because they came from a template) and
|
|
||||||
// stateReplaceFieldIfTemplate (a helper that only replaces when the current value
|
|
||||||
// is a template default or absent). Handlers must consult this table rather than
|
|
||||||
// writing unconditionally.
|
|
||||||
//
|
|
||||||
// The 7 baseline cases verified here:
|
|
||||||
//
|
|
||||||
// 1. record-session WITHOUT --resume-file when Resume File is executor-authored
|
|
||||||
// → preserved (must NOT be replaced with 'None')
|
|
||||||
// 2. record-session WITHOUT --resume-file when Resume File is 'None'
|
|
||||||
// → remains 'None' (template-default → template-default is fine)
|
|
||||||
// 3. record-session WITH --resume-file → explicit caller value wins (always)
|
|
||||||
// 4. advance-plan phase-complete when Status is executor-authored → preserved
|
|
||||||
// 5. advance-plan phase-complete when Status is a known default → replaced
|
|
||||||
// 6. advance-plan advance when Last Activity is executor-authored → preserved
|
|
||||||
// 7. updateCurrentPositionFields with executor-authored Current Position values
|
|
||||||
// → preserved
|
|
||||||
|
|
||||||
const { describe, 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 { cleanup } = require('./helpers.cjs');
|
|
||||||
|
|
||||||
const ROOT = path.join(__dirname, '..');
|
|
||||||
const TOOLS_PATH = path.join(ROOT, 'gsd-core', 'bin', 'gsd-tools.cjs');
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function makeTempPlanning(stateContent) {
|
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-397-'));
|
|
||||||
const planningDir = path.join(dir, '.planning');
|
|
||||||
fs.mkdirSync(planningDir, { recursive: true });
|
|
||||||
fs.writeFileSync(path.join(planningDir, 'STATE.md'), stateContent, 'utf8');
|
|
||||||
return dir;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readState(dir) {
|
|
||||||
return fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8');
|
|
||||||
}
|
|
||||||
|
|
||||||
function runGsdState(args, cwd) {
|
|
||||||
const { execFileSync } = require('child_process');
|
|
||||||
const env = {
|
|
||||||
...process.env,
|
|
||||||
GSD_SESSION_KEY: '',
|
|
||||||
CODEX_THREAD_ID: '',
|
|
||||||
CLAUDE_SESSION_ID: '',
|
|
||||||
CLAUDE_CODE_SSE_PORT: '',
|
|
||||||
OPENCODE_SESSION_ID: '',
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
execFileSync(process.execPath, [TOOLS_PATH, 'state', ...args], {
|
|
||||||
cwd,
|
|
||||||
encoding: 'utf-8',
|
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
|
||||||
env,
|
|
||||||
});
|
|
||||||
return { success: true };
|
|
||||||
} catch (err) {
|
|
||||||
return { success: false, error: err.stderr?.toString().trim() || err.message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Fixtures
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// Case 1: Resume File is executor-authored (not a template default)
|
|
||||||
const STATE_EXECUTOR_RESUME_FILE = `# GSD State
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
Current Phase: 2
|
|
||||||
Total Plans in Phase: 4
|
|
||||||
Current Plan: 1
|
|
||||||
Status: Ready to execute
|
|
||||||
|
|
||||||
## Current Position
|
|
||||||
Phase: 2
|
|
||||||
Plan: 1 of 4
|
|
||||||
Status: Ready to execute
|
|
||||||
Last activity: 2026-01-01
|
|
||||||
|
|
||||||
## Session Continuity
|
|
||||||
Last session: 2026-01-01T00:00:00.000Z
|
|
||||||
Last Date: 2026-01-01T00:00:00.000Z
|
|
||||||
Resume File: /home/user/my-custom-context.md
|
|
||||||
Stopped At: Phase 2 Plan 1 complete
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Case 2: Resume File is 'None' (the known template default)
|
|
||||||
const STATE_DEFAULT_RESUME_FILE = `# GSD State
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
Current Phase: 2
|
|
||||||
Total Plans in Phase: 4
|
|
||||||
Current Plan: 1
|
|
||||||
Status: Ready to execute
|
|
||||||
|
|
||||||
## Current Position
|
|
||||||
Phase: 2
|
|
||||||
Plan: 1 of 4
|
|
||||||
Status: Ready to execute
|
|
||||||
Last activity: 2026-01-01
|
|
||||||
|
|
||||||
## Session Continuity
|
|
||||||
Last session: 2026-01-01T00:00:00.000Z
|
|
||||||
Last Date: 2026-01-01T00:00:00.000Z
|
|
||||||
Resume File: None
|
|
||||||
Stopped At: Phase 2 Plan 1 complete
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Cases 4 & 7: Status is executor-authored in both Configuration and Current Position.
|
|
||||||
// Current Plan=2, Total Plans=2 → triggers the phase-complete branch of advance-plan.
|
|
||||||
const STATE_EXECUTOR_STATUS = `# GSD State
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
Current Phase: 3
|
|
||||||
Total Plans in Phase: 2
|
|
||||||
Current Plan: 2
|
|
||||||
Status: Awaiting QA sign-off before proceeding
|
|
||||||
|
|
||||||
## Current Position
|
|
||||||
Phase: 3
|
|
||||||
Plan: 2 of 2
|
|
||||||
Status: Awaiting QA sign-off before proceeding
|
|
||||||
Last activity: 2026-01-01
|
|
||||||
|
|
||||||
## Session Continuity
|
|
||||||
Last session: 2026-01-01T00:00:00.000Z
|
|
||||||
Last Date: 2026-01-01T00:00:00.000Z
|
|
||||||
Resume File: None
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Case 5: Status IS a known template default ('Ready to execute').
|
|
||||||
// Current Plan=2, Total Plans=2 → triggers the phase-complete branch.
|
|
||||||
const STATE_DEFAULT_STATUS = `# GSD State
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
Current Phase: 3
|
|
||||||
Total Plans in Phase: 2
|
|
||||||
Current Plan: 2
|
|
||||||
Status: Ready to execute
|
|
||||||
|
|
||||||
## Current Position
|
|
||||||
Phase: 3
|
|
||||||
Plan: 2 of 2
|
|
||||||
Status: Ready to execute
|
|
||||||
Last activity: 2026-01-01
|
|
||||||
|
|
||||||
## Session Continuity
|
|
||||||
Last session: 2026-01-01T00:00:00.000Z
|
|
||||||
Last Date: 2026-01-01T00:00:00.000Z
|
|
||||||
Resume File: None
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Case 6: The only Last Activity field in the document is executor-authored
|
|
||||||
// (a narrative, not a bare ISO date). Current Plan=1, Total=3 → advance branch.
|
|
||||||
const STATE_EXECUTOR_LAST_ACTIVITY = `# GSD State
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
Current Phase: 2
|
|
||||||
Total Plans in Phase: 3
|
|
||||||
Current Plan: 1
|
|
||||||
Status: Ready to execute
|
|
||||||
Last Activity: Unblocked after infra fix — merged PR #88 manually
|
|
||||||
|
|
||||||
## Current Position
|
|
||||||
Phase: 2
|
|
||||||
Plan: 1 of 3
|
|
||||||
Status: Ready to execute
|
|
||||||
Last activity: 2026-01-01
|
|
||||||
|
|
||||||
## Session Continuity
|
|
||||||
Last session: 2026-01-01T00:00:00.000Z
|
|
||||||
Last Date: 2026-01-01T00:00:00.000Z
|
|
||||||
Resume File: None
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Case 7: Current Position has executor-authored Status and Last activity.
|
|
||||||
// Current Plan=2, Total=3 → advance branch.
|
|
||||||
const STATE_EXECUTOR_CURRENT_POSITION = `# GSD State
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
Current Phase: 4
|
|
||||||
Total Plans in Phase: 3
|
|
||||||
Current Plan: 2
|
|
||||||
Status: Ready to execute
|
|
||||||
|
|
||||||
## Current Position
|
|
||||||
Phase: 4
|
|
||||||
Plan: 2 of 3
|
|
||||||
Status: On hold — waiting for upstream dependency merge
|
|
||||||
Last activity: 2026-02-15 -- blocked by infra; resume after merge
|
|
||||||
|
|
||||||
## Session Continuity
|
|
||||||
Last session: 2026-01-01T00:00:00.000Z
|
|
||||||
Last Date: 2026-01-01T00:00:00.000Z
|
|
||||||
Resume File: None
|
|
||||||
`;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tests
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
describe('bug #397: executor-authored STATE.md fields must be preserved', () => {
|
|
||||||
|
|
||||||
// Case 1: record-session without --resume-file, Resume File is executor-authored
|
|
||||||
test('case 1: record-session without --resume-file preserves executor-authored Resume File', () => {
|
|
||||||
const dir = makeTempPlanning(STATE_EXECUTOR_RESUME_FILE);
|
|
||||||
try {
|
|
||||||
const r = runGsdState(['record-session', '--stopped-at', 'Plan 1 complete'], dir);
|
|
||||||
assert.ok(r.success, `record-session failed: ${r.error}`);
|
|
||||||
const after = readState(dir);
|
|
||||||
const rfMatch = after.match(/Resume File:\s*(.+)/i);
|
|
||||||
assert.ok(rfMatch, 'Resume File field not found in STATE.md after record-session');
|
|
||||||
assert.strictEqual(
|
|
||||||
rfMatch[1].trim(),
|
|
||||||
'/home/user/my-custom-context.md',
|
|
||||||
`record-session overwrote executor-authored Resume File with '${rfMatch[1].trim()}'`,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
cleanup(dir);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Case 2: record-session without --resume-file, Resume File is 'None' (template default)
|
|
||||||
test('case 2: record-session without --resume-file keeps "None" when it is already "None"', () => {
|
|
||||||
const dir = makeTempPlanning(STATE_DEFAULT_RESUME_FILE);
|
|
||||||
try {
|
|
||||||
const r = runGsdState(['record-session', '--stopped-at', 'Plan complete'], dir);
|
|
||||||
assert.ok(r.success, `record-session failed: ${r.error}`);
|
|
||||||
const after = readState(dir);
|
|
||||||
const rfMatch = after.match(/Resume File:\s*(.+)/i);
|
|
||||||
assert.ok(rfMatch, 'Resume File field not found in STATE.md after record-session');
|
|
||||||
assert.strictEqual(
|
|
||||||
rfMatch[1].trim(),
|
|
||||||
'None',
|
|
||||||
`Expected 'None' to remain when it was already 'None', got: ${rfMatch[1].trim()}`,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
cleanup(dir);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Case 3: record-session WITH --resume-file — explicit value always wins
|
|
||||||
test('case 3: record-session with --resume-file sets the explicit value', () => {
|
|
||||||
const dir = makeTempPlanning(STATE_EXECUTOR_RESUME_FILE);
|
|
||||||
try {
|
|
||||||
const r = runGsdState(['record-session', '--resume-file', '/tmp/new-resume.md'], dir);
|
|
||||||
assert.ok(r.success, `record-session failed: ${r.error}`);
|
|
||||||
const after = readState(dir);
|
|
||||||
const rfMatch = after.match(/Resume File:\s*(.+)/i);
|
|
||||||
assert.ok(rfMatch, 'Resume File field not found in STATE.md after record-session');
|
|
||||||
assert.strictEqual(
|
|
||||||
rfMatch[1].trim(),
|
|
||||||
'/tmp/new-resume.md',
|
|
||||||
`Expected explicit --resume-file value to be written, got: ${rfMatch[1].trim()}`,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
cleanup(dir);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Case 4: advance-plan phase-complete, Status is executor-authored → preserved
|
|
||||||
test('case 4: advance-plan (phase-complete) preserves executor-authored Status', () => {
|
|
||||||
const dir = makeTempPlanning(STATE_EXECUTOR_STATUS);
|
|
||||||
try {
|
|
||||||
// Current Plan=2, Total=2 → phase-complete branch
|
|
||||||
const r = runGsdState(['advance-plan'], dir);
|
|
||||||
assert.ok(r.success, `advance-plan failed: ${r.error}`);
|
|
||||||
const after = readState(dir);
|
|
||||||
// The Configuration-level Status must not be clobbered
|
|
||||||
const statusMatch = after.match(/^Status:\s*(.+)/m);
|
|
||||||
assert.ok(statusMatch, 'Status field not found after advance-plan');
|
|
||||||
assert.strictEqual(
|
|
||||||
statusMatch[1].trim(),
|
|
||||||
'Awaiting QA sign-off before proceeding',
|
|
||||||
`advance-plan overwrote executor-authored Status: got '${statusMatch[1].trim()}'`,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
cleanup(dir);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Case 5: advance-plan phase-complete, Status is a known default → replaced
|
|
||||||
test('case 5: advance-plan (phase-complete) replaces known-default Status with phase-complete value', () => {
|
|
||||||
const dir = makeTempPlanning(STATE_DEFAULT_STATUS);
|
|
||||||
try {
|
|
||||||
// Current Plan=2, Total=2 → phase-complete branch
|
|
||||||
const r = runGsdState(['advance-plan'], dir);
|
|
||||||
assert.ok(r.success, `advance-plan failed: ${r.error}`);
|
|
||||||
const after = readState(dir);
|
|
||||||
const statusMatch = after.match(/^Status:\s*(.+)/m);
|
|
||||||
assert.ok(statusMatch, 'Status field not found after advance-plan');
|
|
||||||
// 'Ready to execute' is a known default and should be replaced
|
|
||||||
assert.notStrictEqual(
|
|
||||||
statusMatch[1].trim(),
|
|
||||||
'Ready to execute',
|
|
||||||
`Status should have been updated from 'Ready to execute' after phase-complete, but was not`,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
statusMatch[1].includes('Phase complete') || statusMatch[1].includes('ready for verification'),
|
|
||||||
`Expected phase-complete Status text, got: '${statusMatch[1].trim()}'`,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
cleanup(dir);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Case 6: advance-plan normal advance, top-level Last Activity is executor-authored → preserved
|
|
||||||
test('case 6: advance-plan (normal advance) preserves executor-authored Last Activity', () => {
|
|
||||||
const dir = makeTempPlanning(STATE_EXECUTOR_LAST_ACTIVITY);
|
|
||||||
try {
|
|
||||||
// Current Plan=1, Total=3 → advance branch
|
|
||||||
const r = runGsdState(['advance-plan'], dir);
|
|
||||||
assert.ok(r.success, `advance-plan failed: ${r.error}`);
|
|
||||||
const after = readState(dir);
|
|
||||||
// The top-level Last Activity (in Configuration section) must be preserved
|
|
||||||
const laMatch = after.match(/^Last Activity:\s*(.+)/im);
|
|
||||||
assert.ok(laMatch, 'Last Activity field not found after advance-plan');
|
|
||||||
assert.strictEqual(
|
|
||||||
laMatch[1].trim(),
|
|
||||||
'Unblocked after infra fix — merged PR #88 manually',
|
|
||||||
`advance-plan overwrote executor-authored Last Activity: got '${laMatch[1].trim()}'`,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
cleanup(dir);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Case 7: advance-plan preserves executor-authored Status and Last activity in Current Position
|
|
||||||
test('case 7: advance-plan preserves executor-authored Current Position Status and Last activity', () => {
|
|
||||||
const dir = makeTempPlanning(STATE_EXECUTOR_CURRENT_POSITION);
|
|
||||||
try {
|
|
||||||
// Current Plan=2, Total=3 → advance branch
|
|
||||||
const r = runGsdState(['advance-plan'], dir);
|
|
||||||
assert.ok(r.success, `advance-plan failed: ${r.error}`);
|
|
||||||
const after = readState(dir);
|
|
||||||
const posMatch = after.match(/##\s*Current Position\s*\n([\s\S]*?)(?=\n##|$)/i);
|
|
||||||
assert.ok(posMatch, 'Current Position section not found after advance-plan');
|
|
||||||
const posBody = posMatch[1];
|
|
||||||
const posStatusMatch = posBody.match(/^Status:\s*(.+)/m);
|
|
||||||
assert.ok(posStatusMatch, 'Status field not found in Current Position section');
|
|
||||||
assert.strictEqual(
|
|
||||||
posStatusMatch[1].trim(),
|
|
||||||
'On hold — waiting for upstream dependency merge',
|
|
||||||
`advance-plan overwrote executor-authored Current Position Status: got '${posStatusMatch[1].trim()}'`,
|
|
||||||
);
|
|
||||||
const posActivityMatch = posBody.match(/^Last activity:\s*(.+)/im);
|
|
||||||
assert.ok(posActivityMatch, 'Last activity field not found in Current Position section');
|
|
||||||
assert.ok(
|
|
||||||
posActivityMatch[1].includes('blocked by infra'),
|
|
||||||
`advance-plan overwrote executor-authored Current Position Last activity: got '${posActivityMatch[1].trim()}'`,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
cleanup(dir);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
||||||
@@ -1,212 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Regression tests for issue #416 (open-gsd/gsd-core).
|
|
||||||
*
|
|
||||||
* Bug: getActiveMilestoneArchiveDir falls back to the newest archive directory
|
|
||||||
* when STATE.md names a milestone that has no matching archive yet, producing
|
|
||||||
* W007 false positives for phases from a prior (completed) milestone.
|
|
||||||
*
|
|
||||||
* Fix: when STATE.md is present and parseable and names a milestone, but no
|
|
||||||
* milestones/<vX.Y>-phases/ directory matches, return null. The version-sort
|
|
||||||
* fallback to the newest archive fires only when STATE.md is absent or
|
|
||||||
* unparseable.
|
|
||||||
*
|
|
||||||
* Knuth invariant: the resolver answers one question —
|
|
||||||
* "what archive directory holds the active milestone's phases?"
|
|
||||||
* Answer space: <dir> | null.
|
|
||||||
*/
|
|
||||||
|
|
||||||
process.env.GSD_TEST_MODE = '1';
|
|
||||||
|
|
||||||
const { describe, test, before, after } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
const os = require('node:os');
|
|
||||||
|
|
||||||
const { runGsdTools, cleanup } = require('./helpers.cjs');
|
|
||||||
|
|
||||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function mkplanning(base) {
|
|
||||||
const planningDir = path.join(base, '.planning');
|
|
||||||
const phasesDir = path.join(planningDir, 'phases');
|
|
||||||
fs.mkdirSync(phasesDir, { recursive: true });
|
|
||||||
return planningDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeMinimalRoadmap(planningDir, phases) {
|
|
||||||
// phases: array of { num, name, checked }
|
|
||||||
const checkboxes = phases.map(({ num, name, checked }) =>
|
|
||||||
`- [${checked ? 'x' : ' '}] **Phase ${num}:** ${name}`,
|
|
||||||
).join('\n');
|
|
||||||
const headings = phases.map(({ num, name }) =>
|
|
||||||
`### Phase ${num}: ${name}\n**Goal:** Completed.\n`,
|
|
||||||
).join('\n');
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(planningDir, 'ROADMAP.md'),
|
|
||||||
`# Roadmap\n\n${checkboxes}\n\n${headings}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeStateMdMilestone(planningDir, milestone) {
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(planningDir, 'STATE.md'),
|
|
||||||
`# State\n\n**milestone:** ${milestone}\n**Current Phase:** 23\n**Status:** In progress\n`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeProjectMd(planningDir) {
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(planningDir, 'PROJECT.md'),
|
|
||||||
'# Project\n\n## What This Is\nTest.\n\n## Core Value\nTest.\n\n## Requirements\nTest.\n',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeConfigJson(planningDir) {
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(planningDir, 'config.json'),
|
|
||||||
JSON.stringify({ model_profile: 'balanced' }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function mkArchivePhases(planningDir, version, phaseNums) {
|
|
||||||
// Creates .planning/milestones/<version>-phases/<NN>-phase-name/ dirs
|
|
||||||
const archiveDir = path.join(planningDir, 'milestones', `${version}-phases`);
|
|
||||||
for (const num of phaseNums) {
|
|
||||||
const padded = String(num).padStart(2, '0');
|
|
||||||
fs.mkdirSync(path.join(archiveDir, `${padded}-phase-${num}`), { recursive: true });
|
|
||||||
}
|
|
||||||
return archiveDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Case 1: STATE.md milestone: v6.0, only v5.0-phases/ on disk
|
|
||||||
// → resolver returns null, verifier emits zero W007 for phases 17–22
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('bug #416 case 1: STATE.md v6.0 with only v5.0-phases/ on disk → null, no W007', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
before(() => {
|
|
||||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-416-c1-'));
|
|
||||||
const planningDir = mkplanning(tmpDir);
|
|
||||||
writeProjectMd(planningDir);
|
|
||||||
writeConfigJson(planningDir);
|
|
||||||
|
|
||||||
// Active milestone is v6.0 — no archive for it yet (phases live in flat phases/)
|
|
||||||
writeStateMdMilestone(planningDir, 'v6.0');
|
|
||||||
|
|
||||||
// v5.0 was the prior completed milestone; its archive exists on disk
|
|
||||||
mkArchivePhases(planningDir, 'v5.0', [17, 18, 19, 20, 21, 22]);
|
|
||||||
|
|
||||||
// ROADMAP reflects only v6.0 phases (v5.0 phases are in a prior milestone,
|
|
||||||
// not in the current roadmap section)
|
|
||||||
writeMinimalRoadmap(planningDir, [
|
|
||||||
{ num: 23, name: 'New Foundation', checked: false },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
after(() => { cleanup(tmpDir); });
|
|
||||||
|
|
||||||
test('validate health emits zero W007 warnings (no prior-milestone phases surfaced)', () => {
|
|
||||||
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
|
|
||||||
assert.strictEqual(result.success, true, `validate health failed: ${result.error}`);
|
|
||||||
const data = JSON.parse(result.output);
|
|
||||||
const w007 = (data.warnings ?? []).filter((w) => w.code === 'W007');
|
|
||||||
assert.strictEqual(
|
|
||||||
w007.length,
|
|
||||||
0,
|
|
||||||
`Expected zero W007 — phases 17–22 from v5.0-phases/ must not appear as "active".\nGot: ${JSON.stringify(w007, null, 2)}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Case 2: No STATE.md, multiple archives on disk → version-sort fallback
|
|
||||||
// returns the highest-versioned archive (existing behavior preserved)
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('bug #416 case 2: no STATE.md + multiple archives → version-sort fallback to newest', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
before(() => {
|
|
||||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-416-c2-'));
|
|
||||||
const planningDir = mkplanning(tmpDir);
|
|
||||||
writeProjectMd(planningDir);
|
|
||||||
writeConfigJson(planningDir);
|
|
||||||
|
|
||||||
// No STATE.md — resolver must use the version-sort fallback
|
|
||||||
// Two archives: v4.0 and v5.0; v5.0 is newer
|
|
||||||
mkArchivePhases(planningDir, 'v4.0', [10, 11, 12]);
|
|
||||||
mkArchivePhases(planningDir, 'v5.0', [17, 18, 19]);
|
|
||||||
|
|
||||||
// ROADMAP lists v5.0 phases so W007 does not fire
|
|
||||||
writeMinimalRoadmap(planningDir, [
|
|
||||||
{ num: 17, name: 'Alpha', checked: true },
|
|
||||||
{ num: 18, name: 'Beta', checked: true },
|
|
||||||
{ num: 19, name: 'Gamma', checked: true },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
after(() => { cleanup(tmpDir); });
|
|
||||||
|
|
||||||
test('validate health succeeds and does not emit W007 for v5.0 archive phases', () => {
|
|
||||||
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
|
|
||||||
assert.strictEqual(result.success, true, `validate health failed: ${result.error}`);
|
|
||||||
const data = JSON.parse(result.output);
|
|
||||||
const w007 = (data.warnings ?? []).filter((w) => w.code === 'W007');
|
|
||||||
// v5.0 phases (17–19) are in the archive returned by the fallback and
|
|
||||||
// in the ROADMAP, so no W007 should fire.
|
|
||||||
assert.strictEqual(
|
|
||||||
w007.length,
|
|
||||||
0,
|
|
||||||
`Expected zero W007 for v5.0 archive phases present in ROADMAP.\nGot: ${JSON.stringify(w007, null, 2)}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Case 3: STATE.md milestone: v5.0, matching v5.0-phases/ exists → returns it
|
|
||||||
// (regression guard — happy path must not break)
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('bug #416 case 3: STATE.md v5.0 with matching v5.0-phases/ → returns archive dir', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
before(() => {
|
|
||||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-416-c3-'));
|
|
||||||
const planningDir = mkplanning(tmpDir);
|
|
||||||
writeProjectMd(planningDir);
|
|
||||||
writeConfigJson(planningDir);
|
|
||||||
|
|
||||||
// STATE.md names v5.0 and a matching archive exists
|
|
||||||
writeStateMdMilestone(planningDir, 'v5.0');
|
|
||||||
mkArchivePhases(planningDir, 'v5.0', [17, 18, 19, 20, 21, 22]);
|
|
||||||
|
|
||||||
// ROADMAP lists v5.0 phases so W007 does not fire
|
|
||||||
writeMinimalRoadmap(planningDir, [
|
|
||||||
{ num: 17, name: 'Alpha', checked: true },
|
|
||||||
{ num: 18, name: 'Beta', checked: true },
|
|
||||||
{ num: 19, name: 'Gamma', checked: true },
|
|
||||||
{ num: 20, name: 'Delta', checked: true },
|
|
||||||
{ num: 21, name: 'Epsilon', checked: true },
|
|
||||||
{ num: 22, name: 'Zeta', checked: true },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
after(() => { cleanup(tmpDir); });
|
|
||||||
|
|
||||||
test('validate health emits zero W007 — archive phases are in ROADMAP and active', () => {
|
|
||||||
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
|
|
||||||
assert.strictEqual(result.success, true, `validate health failed: ${result.error}`);
|
|
||||||
const data = JSON.parse(result.output);
|
|
||||||
const w007 = (data.warnings ?? []).filter((w) => w.code === 'W007');
|
|
||||||
assert.strictEqual(
|
|
||||||
w007.length,
|
|
||||||
0,
|
|
||||||
`Expected zero W007 for matching v5.0 archive with v5.0 in STATE.md.\nGot: ${JSON.stringify(w007, null, 2)}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
/**
|
|
||||||
* Bug #501: extractCurrentMilestone leaks prior-milestone phases when the
|
|
||||||
* ROADMAP uses a flat shared "## Phase Details" section.
|
|
||||||
*
|
|
||||||
* extractCurrentMilestone returns `preamble + currentSection`, where the
|
|
||||||
* preamble is everything before the first milestone heading (only <details>
|
|
||||||
* blocks stripped). A flat "## Phase Details" section listing every phase
|
|
||||||
* across all milestones therefore leaks its `### Phase N:` headings into the
|
|
||||||
* active-milestone scope, so getMilestonePhaseFilter / buildStateFrontmatter
|
|
||||||
* count the whole project instead of just the active milestone.
|
|
||||||
*
|
|
||||||
* Maintainer direction (triage of #501): fix in code AND make
|
|
||||||
* `validate consistency` milestone-aware so it does not flag shipped phase
|
|
||||||
* dirs as orphans once the scope is correctly narrowed.
|
|
||||||
*
|
|
||||||
* Layout under test (mirrors the real repro):
|
|
||||||
* # Roadmap
|
|
||||||
* ## Phase Details <- flat, BEFORE the first milestone heading
|
|
||||||
* ### Phase 1..3 <- shipped phases
|
|
||||||
* ## ✅ v2.0 <- shipped milestone
|
|
||||||
* ## 🚧 v3.0 (active)
|
|
||||||
* ### Phase 4..5 <- active-milestone phases
|
|
||||||
* STATE.md milestone: v3.0 → state json must report total_phases: 2.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
|
||||||
|
|
||||||
const ROADMAP = `# Roadmap
|
|
||||||
|
|
||||||
Project overview prose that legitimately lives before the milestones.
|
|
||||||
|
|
||||||
## Phase Details
|
|
||||||
|
|
||||||
### Phase 1: Shipped One
|
|
||||||
Did a thing.
|
|
||||||
|
|
||||||
### Phase 2: Shipped Two
|
|
||||||
Did another thing.
|
|
||||||
|
|
||||||
### Phase 3: Shipped Three
|
|
||||||
Did a third thing.
|
|
||||||
|
|
||||||
## ✅ v2.0: Foundation (shipped)
|
|
||||||
|
|
||||||
Summary of the shipped milestone.
|
|
||||||
|
|
||||||
## 🚧 v3.0: Active Milestone
|
|
||||||
|
|
||||||
### Phase 4: Active One
|
|
||||||
Doing a thing.
|
|
||||||
|
|
||||||
### Phase 5: Active Two
|
|
||||||
Doing another thing.
|
|
||||||
`;
|
|
||||||
|
|
||||||
const STATE = `---
|
|
||||||
gsd_state_version: 1.0
|
|
||||||
milestone: v3.0
|
|
||||||
milestone_name: Active Milestone
|
|
||||||
status: in_progress
|
|
||||||
progress:
|
|
||||||
total_phases: 2
|
|
||||||
completed_phases: 0
|
|
||||||
total_plans: 0
|
|
||||||
completed_plans: 0
|
|
||||||
percent: 0
|
|
||||||
---
|
|
||||||
|
|
||||||
# Project State
|
|
||||||
|
|
||||||
## Current Position
|
|
||||||
|
|
||||||
Phase: 4 (Active One)
|
|
||||||
`;
|
|
||||||
|
|
||||||
describe('flat "## Phase Details" milestone leak (#501)', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = createTempProject();
|
|
||||||
const planning = path.join(tmpDir, '.planning');
|
|
||||||
fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP, 'utf-8');
|
|
||||||
fs.writeFileSync(path.join(planning, 'STATE.md'), STATE, 'utf-8');
|
|
||||||
fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8');
|
|
||||||
// All five phase dirs exist on disk (the flat layout retains shipped dirs).
|
|
||||||
const phaseDirs = ['01-shipped-one', '02-shipped-two', '03-shipped-three', '04-active-one', '05-active-two'];
|
|
||||||
for (const d of phaseDirs) {
|
|
||||||
const dir = path.join(planning, 'phases', d);
|
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
|
||||||
fs.writeFileSync(path.join(dir, '01-PLAN.md'), '# Plan\n', 'utf-8');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('state json counts only the active milestone phases, not the flat Phase Details list', () => {
|
|
||||||
const result = runGsdTools(['state', 'json'], tmpDir);
|
|
||||||
assert.equal(result.success, true, result.error || result.output);
|
|
||||||
const state = JSON.parse(result.output);
|
|
||||||
assert.equal(
|
|
||||||
state.progress.total_phases,
|
|
||||||
2,
|
|
||||||
`active milestone v3.0 has 2 phases (4,5); flat Phase Details (1-3) must not leak. Got total_phases=${state.progress.total_phases}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('validate consistency does not flag shipped phase dirs as not-in-ROADMAP', () => {
|
|
||||||
// Once milestone scope is correctly narrowed (Test A), the shipped phase
|
|
||||||
// dirs (1-3) are no longer in the SCOPED roadmap. They are, however, real
|
|
||||||
// phases listed in the FULL roadmap, so they must NOT be reported as
|
|
||||||
// "exists on disk but not in ROADMAP" orphans. (#501 — validate must be
|
|
||||||
// milestone-aware.)
|
|
||||||
const result = runGsdTools(['validate', 'consistency'], tmpDir);
|
|
||||||
const payload = JSON.parse(result.output);
|
|
||||||
const warnings = payload.warnings || [];
|
|
||||||
const orphanWarnings = warnings.filter((w) => /exists on disk but not in ROADMAP/i.test(w));
|
|
||||||
assert.deepEqual(
|
|
||||||
orphanWarnings,
|
|
||||||
[],
|
|
||||||
`shipped phase dirs (1-3) are in the full ROADMAP and must not be flagged as orphans. Got: ${JSON.stringify(orphanWarnings)}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('validate health (W007) does not flag shipped phase dirs as not-in-ROADMAP', () => {
|
|
||||||
// cmdValidateHealth's Check 8 has the same coupling: its W007 membership
|
|
||||||
// check compared active disk phases against the active-milestone scope.
|
|
||||||
// Shipped phase dirs in the active phases/ dir must be checked against the
|
|
||||||
// FULL roadmap so they are not false W007 orphans. (#501)
|
|
||||||
const result = runGsdTools(['validate', 'health'], tmpDir);
|
|
||||||
const payload = JSON.parse(result.output);
|
|
||||||
const warnings = payload.warnings || [];
|
|
||||||
const w007Orphans = warnings.filter(
|
|
||||||
(w) => w.code === 'W007' && /exists on disk but not in ROADMAP/i.test(w.message)
|
|
||||||
);
|
|
||||||
assert.deepEqual(
|
|
||||||
w007Orphans,
|
|
||||||
[],
|
|
||||||
`shipped phase dirs (1-3) must not produce W007. Got: ${JSON.stringify(w007Orphans.map((w) => w.message))}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Regression test for bug #549:
|
|
||||||
* STATE.md progress.total_phases is over-counted by 1 when the ROADMAP contains
|
|
||||||
* a non-phase section heading that happens to match the broader pattern used by
|
|
||||||
* getMilestonePhaseFilter (e.g. `## Phase Overview:`, `## Phase Details:`).
|
|
||||||
*
|
|
||||||
* Root cause:
|
|
||||||
* buildStateFrontmatter sources total_phases from getMilestonePhaseFilter.phaseCount,
|
|
||||||
* which uses the looser regex `#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:` to build its
|
|
||||||
* milestonePhaseNums set. That pattern matches section headings like
|
|
||||||
* `## Phase Overview:` and `## Phase Details:`, adding non-numeric tokens
|
|
||||||
* ("Overview", "Details") to the set and inflating phaseCount by 1 per heading.
|
|
||||||
*
|
|
||||||
* roadmap.analyze uses the stricter pattern
|
|
||||||
* `#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)` which requires a
|
|
||||||
* leading digit, so it only counts real phase headings.
|
|
||||||
*
|
|
||||||
* Fix: buildStateFrontmatter (and cmdStateSync) must source total_phases from
|
|
||||||
* the same digit-anchored phase-heading parser as roadmap.analyze — single
|
|
||||||
* source of truth.
|
|
||||||
*
|
|
||||||
* Scenario under test:
|
|
||||||
* ROADMAP with 6 integer phases (01-06) + 1 inserted decimal phase (05.1) = 7
|
|
||||||
* phases, plus a `## Phase Overview:` section header.
|
|
||||||
*
|
|
||||||
* BEFORE fix: state json / state sync report total_phases: 8 (7 + 1 spurious
|
|
||||||
* "Overview" token from the getMilestonePhaseFilter pattern).
|
|
||||||
* AFTER fix: state json / state sync report total_phases: 7, matching
|
|
||||||
* roadmap.analyze.phase_count.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
|
||||||
|
|
||||||
// ROADMAP: 6 integer phases + 1 inserted decimal + `## Phase Overview:` section header.
|
|
||||||
// The section header must include a trailing `:` so it matches the getMilestonePhaseFilter
|
|
||||||
// broader pattern (the bug trigger).
|
|
||||||
const ROADMAP = `## Milestone v1.0: Test Milestone
|
|
||||||
|
|
||||||
## Phase Overview:
|
|
||||||
|
|
||||||
High-level narrative about the phases.
|
|
||||||
|
|
||||||
### Phase 01: Alpha
|
|
||||||
**Goal:** alpha
|
|
||||||
|
|
||||||
### Phase 02: Beta
|
|
||||||
**Goal:** beta
|
|
||||||
|
|
||||||
### Phase 03: Gamma
|
|
||||||
**Goal:** gamma
|
|
||||||
|
|
||||||
### Phase 04: Delta
|
|
||||||
**Goal:** delta
|
|
||||||
|
|
||||||
### Phase 05: Epsilon
|
|
||||||
**Goal:** epsilon
|
|
||||||
|
|
||||||
### Phase 05.1: Inserted Hotfix (INSERTED)
|
|
||||||
**Goal:** inserted hotfix
|
|
||||||
|
|
||||||
### Phase 06: Zeta
|
|
||||||
**Goal:** zeta
|
|
||||||
`;
|
|
||||||
|
|
||||||
describe('bug #549 — total_phases over-counted by non-phase section headings', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = createTempProject('bug-549-');
|
|
||||||
const planning = path.join(tmpDir, '.planning');
|
|
||||||
|
|
||||||
fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP, 'utf-8');
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(planning, 'STATE.md'),
|
|
||||||
[
|
|
||||||
'---',
|
|
||||||
'gsd_state_version: 1.0',
|
|
||||||
'milestone: v1.0',
|
|
||||||
'milestone_name: Test Milestone',
|
|
||||||
'status: executing',
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'# Project State',
|
|
||||||
'',
|
|
||||||
'## Configuration',
|
|
||||||
'Current Phase: 1',
|
|
||||||
'Status: Executing Phase 1',
|
|
||||||
'Last Activity: 2026-01-01',
|
|
||||||
'',
|
|
||||||
].join('\n'),
|
|
||||||
'utf-8',
|
|
||||||
);
|
|
||||||
fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8');
|
|
||||||
|
|
||||||
// Create 7 phase dirs (6 integer + 1 decimal).
|
|
||||||
const phaseDirs = [
|
|
||||||
'01-alpha',
|
|
||||||
'02-beta',
|
|
||||||
'03-gamma',
|
|
||||||
'04-delta',
|
|
||||||
'05-epsilon',
|
|
||||||
'05.1-inserted-hotfix',
|
|
||||||
'06-zeta',
|
|
||||||
];
|
|
||||||
for (const d of phaseDirs) {
|
|
||||||
const dir = path.join(planning, 'phases', d);
|
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
|
||||||
fs.writeFileSync(path.join(dir, 'PLAN.md'), '# Plan\n', 'utf-8');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('state json total_phases matches roadmap.analyze phase_count (7, not 8)', () => {
|
|
||||||
// Authoritative count from roadmap.analyze — uses the digit-anchored pattern.
|
|
||||||
const analyzeResult = runGsdTools(['roadmap', 'analyze'], tmpDir);
|
|
||||||
assert.ok(analyzeResult.success, `roadmap analyze failed: ${analyzeResult.error}`);
|
|
||||||
const analyzed = JSON.parse(analyzeResult.output);
|
|
||||||
|
|
||||||
assert.equal(
|
|
||||||
analyzed.phase_count,
|
|
||||||
7,
|
|
||||||
`roadmap.analyze should count 7 phases (01-06 + 05.1), got ${analyzed.phase_count}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// State frontmatter must equal the authoritative count.
|
|
||||||
const stateResult = runGsdTools(['state', 'json'], tmpDir);
|
|
||||||
assert.ok(stateResult.success, `state json failed: ${stateResult.error}`);
|
|
||||||
const state = JSON.parse(stateResult.output);
|
|
||||||
|
|
||||||
assert.ok(state.progress, 'state json must return a progress block');
|
|
||||||
assert.equal(
|
|
||||||
state.progress.total_phases,
|
|
||||||
7,
|
|
||||||
`progress.total_phases must be 7 (not 8) — ## Phase Overview: section must not be counted as a phase. Got ${state.progress.total_phases}`,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
state.progress.total_phases,
|
|
||||||
analyzed.phase_count,
|
|
||||||
`progress.total_phases (${state.progress.total_phases}) must equal roadmap.analyze.phase_count (${analyzed.phase_count})`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('state sync total_phases matches roadmap.analyze phase_count', () => {
|
|
||||||
// Add a Progress field to the body so cmdStateSync has something to update.
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
const before = fs.readFileSync(statePath, 'utf-8');
|
|
||||||
fs.writeFileSync(statePath, before.replace('Last Activity: 2026-01-01', 'Last Activity: 2026-01-01\nProgress: [░░░░░░░░░░] 0%'), 'utf-8');
|
|
||||||
|
|
||||||
const syncResult = runGsdTools(['state', 'sync'], tmpDir);
|
|
||||||
assert.ok(syncResult.success, `state sync failed: ${syncResult.error}`);
|
|
||||||
|
|
||||||
// Read frontmatter via state json (authoritative JSON path).
|
|
||||||
const stateResult = runGsdTools(['state', 'json'], tmpDir);
|
|
||||||
assert.ok(stateResult.success, `state json after sync failed: ${stateResult.error}`);
|
|
||||||
const state = JSON.parse(stateResult.output);
|
|
||||||
|
|
||||||
assert.ok(state.progress, 'state json must return a progress block after sync');
|
|
||||||
assert.equal(
|
|
||||||
state.progress.total_phases,
|
|
||||||
7,
|
|
||||||
`state sync must write total_phases: 7, not 8. ## Phase Overview: must not inflate the count. Got ${state.progress.total_phases}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('integer-only project without decimal phase also counts correctly', () => {
|
|
||||||
// Regression guard: the fix must not break projects with no decimal phases.
|
|
||||||
const tmpDir2 = createTempProject('bug-549-integer-');
|
|
||||||
try {
|
|
||||||
const planning2 = path.join(tmpDir2, '.planning');
|
|
||||||
|
|
||||||
// ROADMAP: 4 integer phases only + non-phase section heading.
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(planning2, 'ROADMAP.md'),
|
|
||||||
[
|
|
||||||
'## Milestone v1.0: Simple',
|
|
||||||
'',
|
|
||||||
'## Phase Overview:',
|
|
||||||
'',
|
|
||||||
'### Phase 01: One',
|
|
||||||
'**Goal:** one',
|
|
||||||
'',
|
|
||||||
'### Phase 02: Two',
|
|
||||||
'**Goal:** two',
|
|
||||||
'',
|
|
||||||
'### Phase 03: Three',
|
|
||||||
'**Goal:** three',
|
|
||||||
'',
|
|
||||||
'### Phase 04: Four',
|
|
||||||
'**Goal:** four',
|
|
||||||
].join('\n'),
|
|
||||||
'utf-8',
|
|
||||||
);
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(planning2, 'STATE.md'),
|
|
||||||
'---\ngsd_state_version: 1.0\nmilestone: v1.0\nstatus: executing\n---\n\n# State\n\nStatus: Executing Phase 1\nLast Activity: 2026-01-01\n',
|
|
||||||
'utf-8',
|
|
||||||
);
|
|
||||||
fs.writeFileSync(path.join(planning2, 'config.json'), '{}', 'utf-8');
|
|
||||||
|
|
||||||
for (const d of ['01-one', '02-two', '03-three', '04-four']) {
|
|
||||||
const dir = path.join(planning2, '.planning', 'phases', d);
|
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
|
||||||
fs.writeFileSync(path.join(dir, 'PLAN.md'), '# Plan\n', 'utf-8');
|
|
||||||
}
|
|
||||||
|
|
||||||
const analyzeResult = runGsdTools(['roadmap', 'analyze'], tmpDir2);
|
|
||||||
assert.ok(analyzeResult.success, `roadmap analyze failed: ${analyzeResult.error}`);
|
|
||||||
const analyzed = JSON.parse(analyzeResult.output);
|
|
||||||
assert.equal(analyzed.phase_count, 4, `expected 4 phases, got ${analyzed.phase_count}`);
|
|
||||||
|
|
||||||
const stateResult = runGsdTools(['state', 'json'], tmpDir2);
|
|
||||||
assert.ok(stateResult.success, `state json failed: ${stateResult.error}`);
|
|
||||||
const state = JSON.parse(stateResult.output);
|
|
||||||
assert.ok(state.progress, 'state json must return a progress block');
|
|
||||||
assert.equal(
|
|
||||||
state.progress.total_phases,
|
|
||||||
4,
|
|
||||||
`integer-only project: total_phases must be 4, not 5. Got ${state.progress.total_phases}`,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
cleanup(tmpDir2);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
/**
|
|
||||||
* Bug #557: Active milestone wrapped in <details open> with version only in
|
|
||||||
* <summary> tag + 🔄 emoji causes extractCurrentMilestone() to fall through
|
|
||||||
* to stripShippedMilestones(), erasing the active block and making
|
|
||||||
* roadmap.analyze return phase_count: 0 — which then triggers a premature
|
|
||||||
* milestone_complete STATE write.
|
|
||||||
*
|
|
||||||
* Root cause (two miss paths in extractCurrentMilestone, core.cjs):
|
|
||||||
* 1. sectionPattern only matches ##/### headings; version in <summary> not found.
|
|
||||||
* 2. activeMarkerPattern does not include 🔄; only 🚧 is recognised.
|
|
||||||
* Both misses → stripShippedMilestones() deletes the active <details open> block.
|
|
||||||
*
|
|
||||||
* This test will FAIL before the fix (phase_count returns 0) and PASS after.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
|
||||||
|
|
||||||
// ─── Fixtures ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// ROADMAP where the active milestone's version ("v1.3") appears ONLY inside
|
|
||||||
// a <summary> tag, and the in-progress marker is 🔄 (not 🚧).
|
|
||||||
// Shipped milestone v1.2 is correctly collapsed in a <details> block.
|
|
||||||
const ROADMAP_DETAILS_SUMMARY = `# Roadmap
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>✅ v1.2: Foundation (shipped)</summary>
|
|
||||||
|
|
||||||
### Phase 1: Bootstrap
|
|
||||||
**Goal:** Set up infrastructure
|
|
||||||
|
|
||||||
### Phase 2: Core API
|
|
||||||
**Goal:** Build REST API
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details open>
|
|
||||||
<summary>🔄 v1.3: Active Sprint</summary>
|
|
||||||
|
|
||||||
### Phase 3: Auth
|
|
||||||
**Goal:** Add authentication
|
|
||||||
|
|
||||||
### Phase 4: Dashboard
|
|
||||||
**Goal:** Build dashboard UI
|
|
||||||
|
|
||||||
</details>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// STATE.md with milestone: v1.3 — version matches the <summary> tag above
|
|
||||||
const STATE_V13 = `---
|
|
||||||
gsd_state_version: 1.0
|
|
||||||
milestone: v1.3
|
|
||||||
milestone_name: Active Sprint
|
|
||||||
status: in_progress
|
|
||||||
progress:
|
|
||||||
total_phases: 2
|
|
||||||
completed_phases: 0
|
|
||||||
total_plans: 0
|
|
||||||
completed_plans: 0
|
|
||||||
percent: 0
|
|
||||||
---
|
|
||||||
|
|
||||||
# Project State
|
|
||||||
|
|
||||||
## Current Position
|
|
||||||
|
|
||||||
Phase: 3 (Auth)
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Second variant: active milestone uses the 🔄 emoji in a heading (not just
|
|
||||||
// <summary>) to confirm the activeMarkerPattern gap is also covered.
|
|
||||||
const ROADMAP_ROTATE_HEADING = `# Roadmap
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>✅ v2.0: Shipped (shipped)</summary>
|
|
||||||
|
|
||||||
### Phase 1: Old Phase
|
|
||||||
**Goal:** Done
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
## 🔄 v2.1: Active Milestone
|
|
||||||
|
|
||||||
### Phase 2: New Feature
|
|
||||||
**Goal:** Build the new feature
|
|
||||||
|
|
||||||
### Phase 3: Integration
|
|
||||||
**Goal:** Wire it all together
|
|
||||||
`;
|
|
||||||
|
|
||||||
const STATE_V21 = `---
|
|
||||||
gsd_state_version: 1.0
|
|
||||||
milestone: v2.1
|
|
||||||
milestone_name: Active Milestone
|
|
||||||
status: in_progress
|
|
||||||
---
|
|
||||||
|
|
||||||
# Project State
|
|
||||||
|
|
||||||
## Current Position
|
|
||||||
|
|
||||||
Phase: 2 (New Feature)
|
|
||||||
`;
|
|
||||||
|
|
||||||
// ─── Test suite ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('bug #557 — <details>/<summary> active milestone strip', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = createTempProject();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Core repro: version only in <summary> tag ─────────────────────────────
|
|
||||||
|
|
||||||
test('roadmap.analyze returns correct phase_count when active milestone uses <summary> + 🔄', () => {
|
|
||||||
const planning = path.join(tmpDir, '.planning');
|
|
||||||
fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP_DETAILS_SUMMARY, 'utf-8');
|
|
||||||
fs.writeFileSync(path.join(planning, 'STATE.md'), STATE_V13, 'utf-8');
|
|
||||||
fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8');
|
|
||||||
|
|
||||||
const result = runGsdTools(['roadmap', 'analyze'], tmpDir);
|
|
||||||
assert.ok(result.success, `roadmap.analyze failed: ${result.error}`);
|
|
||||||
|
|
||||||
const output = JSON.parse(result.output);
|
|
||||||
assert.ok(
|
|
||||||
output.phase_count >= 2,
|
|
||||||
`Expected phase_count >= 2 (phases 3 and 4 of v1.3); got phase_count=${output.phase_count}. ` +
|
|
||||||
`Bug: extractCurrentMilestone() stripped the active <details open> block because ` +
|
|
||||||
`the version "v1.3" only appears in a <summary> tag and the emoji is 🔄, not 🚧.`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('roadmap.analyze does NOT return phase_count: 0 when active milestone is in <details open>', () => {
|
|
||||||
const planning = path.join(tmpDir, '.planning');
|
|
||||||
fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP_DETAILS_SUMMARY, 'utf-8');
|
|
||||||
fs.writeFileSync(path.join(planning, 'STATE.md'), STATE_V13, 'utf-8');
|
|
||||||
fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8');
|
|
||||||
|
|
||||||
const result = runGsdTools(['roadmap', 'analyze'], tmpDir);
|
|
||||||
assert.ok(result.success, `roadmap.analyze failed: ${result.error}`);
|
|
||||||
|
|
||||||
const output = JSON.parse(result.output);
|
|
||||||
assert.notStrictEqual(
|
|
||||||
output.phase_count,
|
|
||||||
0,
|
|
||||||
'phase_count must not be 0 — a zero count caused by stripping the active block ' +
|
|
||||||
'is the direct trigger for the premature milestone_complete write.'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('roadmap get-phase returns found:true for phase in active <details open> block', () => {
|
|
||||||
const planning = path.join(tmpDir, '.planning');
|
|
||||||
fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP_DETAILS_SUMMARY, 'utf-8');
|
|
||||||
fs.writeFileSync(path.join(planning, 'STATE.md'), STATE_V13, 'utf-8');
|
|
||||||
fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8');
|
|
||||||
|
|
||||||
const result = runGsdTools(['roadmap', 'get-phase', '3'], tmpDir);
|
|
||||||
assert.ok(result.success, `roadmap get-phase failed: ${result.error}`);
|
|
||||||
|
|
||||||
const output = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(
|
|
||||||
output.found,
|
|
||||||
true,
|
|
||||||
`Phase 3 must be found in the active v1.3 milestone block. ` +
|
|
||||||
`Bug: stripShippedMilestones() erased the <details open> block so the phase section was lost.`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('shipped phases in collapsed <details> are NOT visible to roadmap.analyze (strip preserved for non-active)', () => {
|
|
||||||
const planning = path.join(tmpDir, '.planning');
|
|
||||||
fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP_DETAILS_SUMMARY, 'utf-8');
|
|
||||||
fs.writeFileSync(path.join(planning, 'STATE.md'), STATE_V13, 'utf-8');
|
|
||||||
fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8');
|
|
||||||
|
|
||||||
const result = runGsdTools(['roadmap', 'analyze'], tmpDir);
|
|
||||||
assert.ok(result.success, `roadmap.analyze failed: ${result.error}`);
|
|
||||||
|
|
||||||
const output = JSON.parse(result.output);
|
|
||||||
const phaseNums = (output.phases || []).map(p => p.number);
|
|
||||||
assert.ok(
|
|
||||||
!phaseNums.includes('1') && !phaseNums.includes('2'),
|
|
||||||
`Shipped phases 1 and 2 (from collapsed <details>) must not appear in the analyze output. ` +
|
|
||||||
`Got phases: ${JSON.stringify(phaseNums)}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 🔄 in heading (not <summary>) also recognised ────────────────────────
|
|
||||||
|
|
||||||
test('extractCurrentMilestone recognises 🔄 in milestone heading as in-progress marker', () => {
|
|
||||||
const planning = path.join(tmpDir, '.planning');
|
|
||||||
fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP_ROTATE_HEADING, 'utf-8');
|
|
||||||
fs.writeFileSync(path.join(planning, 'STATE.md'), STATE_V21, 'utf-8');
|
|
||||||
fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8');
|
|
||||||
|
|
||||||
const result = runGsdTools(['roadmap', 'analyze'], tmpDir);
|
|
||||||
assert.ok(result.success, `roadmap.analyze failed: ${result.error}`);
|
|
||||||
|
|
||||||
const output = JSON.parse(result.output);
|
|
||||||
assert.ok(
|
|
||||||
output.phase_count >= 2,
|
|
||||||
`Expected phase_count >= 2 for v2.1 with 🔄 heading; got ${output.phase_count}. ` +
|
|
||||||
`activeMarkerPattern must include 🔄, not just 🚧.`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Health check W021: milestone_complete vs unstarted phases ─────────────
|
|
||||||
|
|
||||||
test('validate health emits W021 when STATE says milestone complete but ROADMAP has unstarted phases', () => {
|
|
||||||
const planning = path.join(tmpDir, '.planning');
|
|
||||||
// ROADMAP still has active phases in it
|
|
||||||
fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP_DETAILS_SUMMARY, 'utf-8');
|
|
||||||
// STATE falsely says milestone complete
|
|
||||||
fs.writeFileSync(path.join(planning, 'STATE.md'), `---
|
|
||||||
gsd_state_version: 1.0
|
|
||||||
milestone: v1.3
|
|
||||||
milestone_name: Active Sprint
|
|
||||||
status: v1.3 milestone complete
|
|
||||||
---
|
|
||||||
|
|
||||||
# Project State
|
|
||||||
|
|
||||||
## Current Position
|
|
||||||
|
|
||||||
Phase: Milestone v1.3 complete
|
|
||||||
`, 'utf-8');
|
|
||||||
fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8');
|
|
||||||
|
|
||||||
const result = runGsdTools(['validate', 'health'], tmpDir);
|
|
||||||
assert.ok(result.success, `validate health failed: ${result.error}`);
|
|
||||||
|
|
||||||
const output = JSON.parse(result.output);
|
|
||||||
const warnings = output.warnings || [];
|
|
||||||
const w021 = warnings.find(w => w.code === 'W021');
|
|
||||||
assert.ok(
|
|
||||||
w021 !== undefined,
|
|
||||||
`Expected W021 warning (milestone-status vs. roadmap-progress incoherence). ` +
|
|
||||||
`Got warnings: ${JSON.stringify(warnings.map(w => w.code))}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
/**
|
|
||||||
* Regression guard — bug(#853): /gsd-manager and /gsd-autonomous --interactive
|
|
||||||
* silently skipped worktree isolation + independent verification because they
|
|
||||||
* dispatched Plan/Execute via Agent(run_in_background=true). On Claude Code a
|
|
||||||
* backgrounded agent has no Agent/Task tool, so it cannot spawn the nested
|
|
||||||
* subagents (worktree executors, plan-checker, verifier). The workflows must
|
|
||||||
* now resolve dispatch capability from the registry (#1708) and run inline
|
|
||||||
* everywhere except runtimes where dispatch.background && dispatch.backgroundDispatch
|
|
||||||
* are both true (currently: codex, cursor).
|
|
||||||
*
|
|
||||||
* Phase B (#1708): the prose `RUNTIME === 'codex'` rule is graduated to a typed
|
|
||||||
* `gsd_run query dispatch-should-flatten` query backed by shouldFlattenDispatch()
|
|
||||||
* from host-integration.cjs and the documentation-sourced capability registry.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { describe, test } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
const { createTempProject, cleanup: cleanupDir, runGsdTools } = require('./helpers.cjs');
|
|
||||||
|
|
||||||
const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows');
|
|
||||||
// allow-test-rule: source-text-is-the-product (see #1708)
|
|
||||||
const MANAGER = fs.readFileSync(path.join(WORKFLOWS_DIR, 'manager.md'), 'utf8');
|
|
||||||
// allow-test-rule: source-text-is-the-product (see #1708)
|
|
||||||
const AUTONOMOUS = fs.readFileSync(path.join(WORKFLOWS_DIR, 'autonomous.md'), 'utf8');
|
|
||||||
|
|
||||||
describe('bug-853 — manager/autonomous gate background dispatch by runtime', () => {
|
|
||||||
test('manager.md resolves dispatch-should-flatten before dispatching plan/execute', () => {
|
|
||||||
// Two dispatch sites (plan + execute), each must use dispatch-should-flatten.
|
|
||||||
// allow-test-rule: source-text-is-the-product (see #1708)
|
|
||||||
const matches = MANAGER.match(/dispatch-should-flatten/g) || [];
|
|
||||||
assert.ok(matches.length >= 2, 'manager.md must use dispatch-should-flatten for both plan and execute dispatch');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('manager.md documents why most runtimes cannot background-dispatch', () => {
|
|
||||||
// Accept both old singular form (backgrounded agent has no) and new plural form (backgrounded agents have no)
|
|
||||||
// allow-test-rule: source-text-is-the-product (see #1708)
|
|
||||||
assert.match(MANAGER, /backgrounded agents? ha(?:s|ve) no `Agent`\/`Task` tool/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('manager.md gates background dispatch on FLATTEN=false and runs plan/execute inline otherwise', () => {
|
|
||||||
// Background path uses FLATTEN is false
|
|
||||||
// allow-test-rule: source-text-is-the-product (see #1708)
|
|
||||||
assert.match(MANAGER, /If `FLATTEN` is `false`[\s\S]{0,400}?run_in_background=true/);
|
|
||||||
// Inline is the default/else branch for plan — anchored on FLATTEN=true language (not runtime name)
|
|
||||||
assert.match(
|
|
||||||
MANAGER,
|
|
||||||
/Otherwise[\s\S]{0,100}?`FLATTEN`[\s\S]{0,400}?Skill\(skill="gsd-plan-phase"/,
|
|
||||||
);
|
|
||||||
// Inline is the default/else branch for execute — anchored on FLATTEN=true language (not runtime name)
|
|
||||||
assert.match(
|
|
||||||
MANAGER,
|
|
||||||
/Otherwise[\s\S]{0,100}?`FLATTEN`[\s\S]{0,400}?Skill\(skill="gsd-execute-phase"/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('manager.md compound action preamble uses FLATTEN language (not hardcoded runtime names)', () => {
|
|
||||||
// allow-test-rule: source-text-is-the-product (see #1708)
|
|
||||||
const compoundActionSection = MANAGER.match(
|
|
||||||
/### Compound Action \(background \+ inline\)[\s\S]*?Inline verification:/,
|
|
||||||
);
|
|
||||||
assert.ok(compoundActionSection, 'manager.md must document compound action runtime dispatch');
|
|
||||||
|
|
||||||
// Must gate on FLATTEN being false (not runtime name)
|
|
||||||
assert.match(
|
|
||||||
compoundActionSection[0],
|
|
||||||
/If `FLATTEN` is `false`[\s\S]{0,400}?Spawn all background agents first[\s\S]{0,300}?plan\/execute/,
|
|
||||||
);
|
|
||||||
// Otherwise / inline branch must reference FLATTEN being true
|
|
||||||
assert.match(
|
|
||||||
compoundActionSection[0],
|
|
||||||
/Otherwise[\s\S]{0,260}?`FLATTEN`[\s\S]{0,260}?`true`[\s\S]{0,260}?inline/,
|
|
||||||
);
|
|
||||||
// Must NOT still hardcode "On Codex:" in this section
|
|
||||||
assert.doesNotMatch(
|
|
||||||
compoundActionSection[0],
|
|
||||||
/\*\*On Codex:\*\*/,
|
|
||||||
);
|
|
||||||
// Must NOT still hardcode "On Claude Code or any other non-Codex runtime:"
|
|
||||||
assert.doesNotMatch(
|
|
||||||
compoundActionSection[0],
|
|
||||||
/On Claude Code or any other non-Codex runtime:/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('autonomous.md gates interactive background dispatch using dispatch-should-flatten', () => {
|
|
||||||
// Two dispatch sites (3b plan + 3c execute), each must use dispatch-should-flatten.
|
|
||||||
// allow-test-rule: source-text-is-the-product (see #1708)
|
|
||||||
const autoFlattenMatches = AUTONOMOUS.match(/dispatch-should-flatten/g) || [];
|
|
||||||
assert.ok(autoFlattenMatches.length >= 2, 'autonomous.md must use dispatch-should-flatten in both 3b (plan) and 3c (execute) interactive branches');
|
|
||||||
// Accept both old singular form (backgrounded agent has no) and new plural form (backgrounded agents have no)
|
|
||||||
assert.match(AUTONOMOUS, /backgrounded agents? ha(?:s|ve) no `Agent`\/`Task` tool/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('autonomous.md gates interactive background dispatch on FLATTEN=false; runs plan/execute inline otherwise', () => {
|
|
||||||
// Background block: run_in_background=true appears within the FLATTEN=false branch and gsd-plan-phase is nearby
|
|
||||||
// allow-test-rule: source-text-is-the-product (see #1708)
|
|
||||||
assert.match(AUTONOMOUS, /If `FLATTEN` is `false`[\s\S]{0,1200}?run_in_background=true[\s\S]{0,600}?gsd-plan-phase/);
|
|
||||||
// Background block: run_in_background=true appears within the FLATTEN=false branch and gsd-execute-phase is nearby
|
|
||||||
assert.match(AUTONOMOUS, /If `FLATTEN` is `false`[\s\S]{0,3000}?run_in_background=true[\s\S]{0,200}?gsd-execute-phase/);
|
|
||||||
// Inline is the otherwise/else branch for plan — anchored on FLATTEN=true language (not runtime name)
|
|
||||||
assert.match(
|
|
||||||
AUTONOMOUS,
|
|
||||||
/Otherwise[\s\S]{0,100}?`FLATTEN`[\s\S]{0,400}?Skill\(skill="gsd-plan-phase"/,
|
|
||||||
);
|
|
||||||
// Inline is the otherwise/else branch for execute — anchored on FLATTEN=true language (not runtime name)
|
|
||||||
assert.match(
|
|
||||||
AUTONOMOUS,
|
|
||||||
/Otherwise[\s\S]{0,100}?`FLATTEN`[\s\S]{0,400}?Skill\(skill="gsd-execute-phase"/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('dispatch-should-flatten query — behavioral', () => {
|
|
||||||
// #853 / #1708: The typed query replaces prose-level RUNTIME===codex checks.
|
|
||||||
// shouldFlattenDispatch returns false only when both dispatch.background AND
|
|
||||||
// dispatch.backgroundDispatch are true in the capability registry.
|
|
||||||
//
|
|
||||||
// Registry values (from host-integration-capability-matrix.md):
|
|
||||||
// codex: background=true, backgroundDispatch=true → shouldFlatten=false (may background)
|
|
||||||
// claude: background=true, backgroundDispatch=false → shouldFlatten=true (must inline)
|
|
||||||
// cursor: background=true, backgroundDispatch=true → shouldFlatten=false (may background)
|
|
||||||
// unknown: no entry → fail-closed → shouldFlatten=true (must inline)
|
|
||||||
|
|
||||||
test('runtime=codex → shouldFlatten=false (background dispatch safe)', () => {
|
|
||||||
const tmpDir = createTempProject();
|
|
||||||
try {
|
|
||||||
const result = runGsdTools(['query', 'dispatch-should-flatten', '--raw'], tmpDir, {
|
|
||||||
GSD_RUNTIME: 'codex',
|
|
||||||
});
|
|
||||||
assert.ok(result.success, `Expected success, got error: ${result.error}`);
|
|
||||||
assert.strictEqual(result.output, 'false', `codex should return false (may background), got: ${result.output}`);
|
|
||||||
} finally {
|
|
||||||
cleanupDir(tmpDir);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('runtime=claude → shouldFlatten=true (must inline)', () => {
|
|
||||||
const tmpDir = createTempProject();
|
|
||||||
try {
|
|
||||||
const result = runGsdTools(['query', 'dispatch-should-flatten', '--raw'], tmpDir, {
|
|
||||||
GSD_RUNTIME: 'claude',
|
|
||||||
});
|
|
||||||
assert.ok(result.success, `Expected success, got error: ${result.error}`);
|
|
||||||
assert.strictEqual(result.output, 'true', `claude should return true (must inline), got: ${result.output}`);
|
|
||||||
} finally {
|
|
||||||
cleanupDir(tmpDir);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('runtime=cursor → shouldFlatten=false (background dispatch safe)', () => {
|
|
||||||
const tmpDir = createTempProject();
|
|
||||||
try {
|
|
||||||
const result = runGsdTools(['query', 'dispatch-should-flatten', '--raw'], tmpDir, {
|
|
||||||
GSD_RUNTIME: 'cursor',
|
|
||||||
});
|
|
||||||
assert.ok(result.success, `Expected success, got error: ${result.error}`);
|
|
||||||
assert.strictEqual(result.output, 'false', `cursor should return false (may background), got: ${result.output}`);
|
|
||||||
} finally {
|
|
||||||
cleanupDir(tmpDir);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('unknown runtime → shouldFlatten=true (fail-closed → must inline)', () => {
|
|
||||||
// An unknown runtime has no registry entry → dispatch is null → fail-closed to true.
|
|
||||||
const tmpDir = createTempProject();
|
|
||||||
try {
|
|
||||||
const result = runGsdTools(['query', 'dispatch-should-flatten', '--raw'], tmpDir, {
|
|
||||||
GSD_RUNTIME: 'unknown-runtime-xyz',
|
|
||||||
});
|
|
||||||
// The query must succeed (exit 0) even for unknown runtimes — fail-closed not crash-closed.
|
|
||||||
assert.ok(result.success, `Expected success (fail-closed), got error: ${result.error}`);
|
|
||||||
assert.strictEqual(result.output, 'true', `unknown runtime should return true (fail-closed), got: ${result.output}`);
|
|
||||||
} finally {
|
|
||||||
cleanupDir(tmpDir);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('--json flag returns structured { runtime, shouldFlatten, dispatch }', () => {
|
|
||||||
const tmpDir = createTempProject();
|
|
||||||
try {
|
|
||||||
const result = runGsdTools(['query', 'dispatch-should-flatten', '--json'], tmpDir, {
|
|
||||||
GSD_RUNTIME: 'codex',
|
|
||||||
});
|
|
||||||
assert.ok(result.success, `Expected success, got error: ${result.error}`);
|
|
||||||
let parsed;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(result.output);
|
|
||||||
} catch {
|
|
||||||
assert.fail(`Expected valid JSON output, got: ${result.output}`);
|
|
||||||
}
|
|
||||||
assert.strictEqual(parsed.runtime, 'codex');
|
|
||||||
assert.strictEqual(parsed.shouldFlatten, false);
|
|
||||||
assert.ok(parsed.dispatch !== null && typeof parsed.dispatch === 'object', 'dispatch should be an object');
|
|
||||||
assert.strictEqual(parsed.dispatch.backgroundDispatch, true);
|
|
||||||
} finally {
|
|
||||||
cleanupDir(tmpDir);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('config.runtime takes precedence when GSD_RUNTIME not set', () => {
|
|
||||||
// GSD_RUNTIME > config.runtime > 'claude'
|
|
||||||
// Write config.json with runtime=codex; no GSD_RUNTIME override.
|
|
||||||
const tmpDir = createTempProject();
|
|
||||||
try {
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(tmpDir, '.planning', 'config.json'),
|
|
||||||
JSON.stringify({ runtime: 'codex' }),
|
|
||||||
'utf-8',
|
|
||||||
);
|
|
||||||
// Override GSD_RUNTIME to '' (empty string) so any ambient value is cleared.
|
|
||||||
// resolveRuntimeNameFromCandidates treats empty string as absent (normalizes
|
|
||||||
// to '' which is falsy → skipped → falls through to config.runtime=codex).
|
|
||||||
// This is the only way to suppress an ambient GSD_RUNTIME since runGsdTools
|
|
||||||
// merges { ...process.env, ...TEST_ENV_BASE, ...env } — passing '' as the
|
|
||||||
// override overwrites the ambient value at the correct merge position.
|
|
||||||
const result = runGsdTools(['query', 'dispatch-should-flatten', '--raw'], tmpDir, {
|
|
||||||
GSD_RUNTIME: '',
|
|
||||||
});
|
|
||||||
// config.runtime=codex with GSD_RUNTIME cleared → codex backgrounds → shouldFlatten=false
|
|
||||||
assert.ok(result.success, `Expected success, got error: ${result.error}`);
|
|
||||||
assert.strictEqual(result.output, 'false', `config.runtime=codex (GSD_RUNTIME cleared) should return false (may background), got: ${result.output}`);
|
|
||||||
} finally {
|
|
||||||
cleanupDir(tmpDir);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
/**
|
|
||||||
* Regression test for bug #866: profile-pipeline temp output dirs must be
|
|
||||||
* created under GSD_TEMP_DIR (path.join(os.tmpdir(), 'gsd')), not directly
|
|
||||||
* under os.tmpdir() root where reapStaleTempFiles() never scans.
|
|
||||||
*
|
|
||||||
* Hardening (adversarial-review follow-up):
|
|
||||||
* - TMPDIR/TEMP/TMP are redirected to a fixture-scoped directory so the child
|
|
||||||
* process's os.tmpdir() returns an isolated root. This prevents the test from
|
|
||||||
* touching the real shared temp root and keeps it out of the production
|
|
||||||
* reaper's view.
|
|
||||||
* - Both sides of the startsWith assertion are realpath-normalized to kill the
|
|
||||||
* macOS /var ↔ /private/var symlink flakiness.
|
|
||||||
* - An explicit exitCode === 0 assertion is added before JSON.parse so a
|
|
||||||
* non-zero early-exit produces a clear failure rather than a confusing parse
|
|
||||||
* error.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const { runGsdTools, createTempDir, cleanup } = require('./helpers.cjs');
|
|
||||||
|
|
||||||
describe('bug-866: profile-pipeline temp dirs under GSD_TEMP_DIR', () => {
|
|
||||||
let tmpDir;
|
|
||||||
let isolatedSysTmp;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = createTempDir('gsd-866-');
|
|
||||||
// Create an isolated os.tmpdir() root inside the fixture so the child
|
|
||||||
// process never writes to the real shared temp dir.
|
|
||||||
isolatedSysTmp = path.join(tmpDir, 'systmp');
|
|
||||||
fs.mkdirSync(isolatedSysTmp, { recursive: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Helper: create a minimal synthetic sessions directory structure
|
|
||||||
function createSessions(root) {
|
|
||||||
const sessionsDir = path.join(root, 'projects');
|
|
||||||
const projectDir = path.join(sessionsDir, 'test-project-866');
|
|
||||||
fs.mkdirSync(projectDir, { recursive: true });
|
|
||||||
const messages = [
|
|
||||||
{ type: 'user', userType: 'external', message: { content: 'fix the login bug' }, timestamp: Date.now() },
|
|
||||||
{ type: 'assistant', message: { content: 'Sure.' }, timestamp: Date.now() },
|
|
||||||
];
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(projectDir, 'session-001.jsonl'),
|
|
||||||
messages.map(m => JSON.stringify(m)).join('\n')
|
|
||||||
);
|
|
||||||
return sessionsDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
test('extract-messages output_file is under GSD_TEMP_DIR, not os.tmpdir() root', () => {
|
|
||||||
const sessionsDir = createSessions(tmpDir);
|
|
||||||
|
|
||||||
// Pass the isolated tmp root so the child's os.tmpdir() = isolatedSysTmp.
|
|
||||||
// Belt-and-suspenders: set all three env vars Node checks (TMPDIR=POSIX,
|
|
||||||
// TEMP+TMP=Windows).
|
|
||||||
const result = runGsdTools(
|
|
||||||
['extract-messages', 'test-project-866', '--path', sessionsDir, '--raw'],
|
|
||||||
tmpDir,
|
|
||||||
{ TMPDIR: isolatedSysTmp, TEMP: isolatedSysTmp, TMP: isolatedSysTmp }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Explicit exitCode check first — parse errors are confusing on non-zero exit.
|
|
||||||
assert.strictEqual(result.exitCode, 0, `extract-messages must exit 0; error: ${result.error}`);
|
|
||||||
assert.ok(result.success, `extract-messages failed: ${result.error}`);
|
|
||||||
|
|
||||||
const out = JSON.parse(result.output);
|
|
||||||
assert.ok(out.output_file, 'should have output_file in result');
|
|
||||||
|
|
||||||
const outputFile = out.output_file;
|
|
||||||
|
|
||||||
// The expected GSD_TEMP_DIR from the child's perspective: isolatedSysTmp/gsd
|
|
||||||
const expectedGsdTempDir = path.join(isolatedSysTmp, 'gsd');
|
|
||||||
|
|
||||||
// Normalize both sides via realpath to kill macOS /var↔/private/var symlink
|
|
||||||
// flakiness. Realpath the existing output_file's parent directory (the file
|
|
||||||
// itself may have been cleaned up already, but the dir will exist).
|
|
||||||
const expectedRoot = fs.realpathSync(expectedGsdTempDir);
|
|
||||||
const outputDir = path.dirname(outputFile);
|
|
||||||
// The output dir must exist since the tool just wrote there; realpath it.
|
|
||||||
const actualDir = fs.realpathSync(outputDir);
|
|
||||||
|
|
||||||
assert.ok(
|
|
||||||
actualDir.startsWith(expectedRoot + path.sep) || actualDir === expectedRoot,
|
|
||||||
`output_file "${outputFile}" must be under GSD_TEMP_DIR "${expectedGsdTempDir}" (realpath: ${expectedRoot}); got dir "${actualDir}"`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Must NOT be directly under the isolated systmp root (i.e., no gsd-pipeline-*
|
|
||||||
// at depth 1 of isolatedSysTmp).
|
|
||||||
const rel = path.relative(isolatedSysTmp, outputFile);
|
|
||||||
const depth1Dir = rel.split(path.sep)[0];
|
|
||||||
assert.ok(
|
|
||||||
!depth1Dir.startsWith('gsd-pipeline-'),
|
|
||||||
`output_file must not be in isolatedSysTmp/gsd-pipeline-* but got depth-1 dir: "${depth1Dir}"`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('profile-sample output_file is under GSD_TEMP_DIR, not os.tmpdir() root', () => {
|
|
||||||
const sessionsDir = createSessions(tmpDir);
|
|
||||||
|
|
||||||
const result = runGsdTools(
|
|
||||||
['profile-sample', '--path', sessionsDir, '--raw'],
|
|
||||||
tmpDir,
|
|
||||||
{ TMPDIR: isolatedSysTmp, TEMP: isolatedSysTmp, TMP: isolatedSysTmp }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Explicit exitCode check first.
|
|
||||||
assert.strictEqual(result.exitCode, 0, `profile-sample must exit 0; error: ${result.error}`);
|
|
||||||
assert.ok(result.success, `profile-sample failed: ${result.error}`);
|
|
||||||
|
|
||||||
const out = JSON.parse(result.output);
|
|
||||||
assert.ok(out.output_file, 'should have output_file in result');
|
|
||||||
|
|
||||||
const outputFile = out.output_file;
|
|
||||||
|
|
||||||
const expectedGsdTempDir = path.join(isolatedSysTmp, 'gsd');
|
|
||||||
|
|
||||||
const expectedRoot = fs.realpathSync(expectedGsdTempDir);
|
|
||||||
const outputDir = path.dirname(outputFile);
|
|
||||||
const actualDir = fs.realpathSync(outputDir);
|
|
||||||
|
|
||||||
assert.ok(
|
|
||||||
actualDir.startsWith(expectedRoot + path.sep) || actualDir === expectedRoot,
|
|
||||||
`output_file "${outputFile}" must be under GSD_TEMP_DIR "${expectedGsdTempDir}" (realpath: ${expectedRoot}); got dir "${actualDir}"`
|
|
||||||
);
|
|
||||||
|
|
||||||
const rel = path.relative(isolatedSysTmp, outputFile);
|
|
||||||
const depth1Dir = rel.split(path.sep)[0];
|
|
||||||
assert.ok(
|
|
||||||
!depth1Dir.startsWith('gsd-profile-'),
|
|
||||||
`output_file must not be in isolatedSysTmp/gsd-profile-* but got depth-1 dir: "${depth1Dir}"`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,617 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
/**
|
|
||||||
* Regression guard for bug #905.
|
|
||||||
*
|
|
||||||
* `syncStateFrontmatter` (src/state.cts) only preserved `status` from existing
|
|
||||||
* frontmatter when the body-derived value was missing/unknown. The scalars
|
|
||||||
* `current_phase`, `current_phase_name`, `current_plan`, and `progress` were
|
|
||||||
* silently stripped whenever `buildStateFrontmatter` could not extract them from
|
|
||||||
* the body text — e.g. when an agent removed the bold `**Current Phase:**`
|
|
||||||
* annotations.
|
|
||||||
*
|
|
||||||
* Fix: mirror the `cmdStateJson` fallback pattern in `syncStateFrontmatter` so
|
|
||||||
* that all four scalars survive a `writeStateMd` / `state sync` call when the
|
|
||||||
* body no longer carries the annotation but the existing frontmatter does.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
|
|
||||||
const { runGsdTools, createTempProject, createTempDir, cleanup, parseFrontmatter } = require('./helpers.cjs');
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Fixture builders
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A STATE.md whose YAML frontmatter holds all four scalars but whose body
|
|
||||||
* does NOT contain the bold `**Current Phase:**` / `**Current Plan:**`
|
|
||||||
* annotations that `buildStateFrontmatter` uses to re-derive them.
|
|
||||||
*
|
|
||||||
* This is the exact scenario that triggered the bug: the body has already lost
|
|
||||||
* the annotations (e.g. because a CLI tool or agent overwrote it), but the
|
|
||||||
* frontmatter still holds the ground-truth values. A subsequent `state sync`
|
|
||||||
* (or any `writeStateMd` call) must not strip them.
|
|
||||||
*/
|
|
||||||
function buildStateMdWithoutBodyAnnotations(opts) {
|
|
||||||
const {
|
|
||||||
currentPhase = 3,
|
|
||||||
currentPhaseName = 'Implementation',
|
|
||||||
currentPlan = 2,
|
|
||||||
progressPercent = 42,
|
|
||||||
} = opts || {};
|
|
||||||
|
|
||||||
return [
|
|
||||||
'---',
|
|
||||||
'gsd_state_version: 1.0',
|
|
||||||
`current_phase: ${currentPhase}`,
|
|
||||||
`current_phase_name: ${currentPhaseName}`,
|
|
||||||
`current_plan: ${currentPlan}`,
|
|
||||||
'status: executing',
|
|
||||||
'progress:',
|
|
||||||
` total_phases: 5`,
|
|
||||||
` completed_phases: 2`,
|
|
||||||
` total_plans: 10`,
|
|
||||||
` completed_plans: 4`,
|
|
||||||
` percent: ${progressPercent}`,
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'# GSD State',
|
|
||||||
'',
|
|
||||||
'## Configuration',
|
|
||||||
// Intentionally omitting "Current Phase:", "Current Phase Name:",
|
|
||||||
// "Current Plan:" body annotations to reproduce the bug scenario.
|
|
||||||
'Status: Executing',
|
|
||||||
'Last Activity: 2026-01-01',
|
|
||||||
'',
|
|
||||||
'## Accumulated Context',
|
|
||||||
'',
|
|
||||||
'### Decisions',
|
|
||||||
'',
|
|
||||||
'- Use Node 22',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Tests
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('#905: syncStateFrontmatter preserves scalars when body annotations are absent', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = createTempProject();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('state sync preserves current_phase from existing frontmatter when body lacks annotation', () => {
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
fs.writeFileSync(statePath, buildStateMdWithoutBodyAnnotations({ currentPhase: 3 }));
|
|
||||||
|
|
||||||
const syncResult = runGsdTools('state sync', tmpDir);
|
|
||||||
assert.ok(syncResult.success, `state sync failed: ${syncResult.error}`);
|
|
||||||
|
|
||||||
const jsonResult = runGsdTools('state json', tmpDir);
|
|
||||||
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
|
|
||||||
|
|
||||||
const fm = JSON.parse(jsonResult.output);
|
|
||||||
assert.strictEqual(
|
|
||||||
fm.current_phase,
|
|
||||||
'3',
|
|
||||||
`current_phase must be preserved from existing frontmatter after sync (got: ${JSON.stringify(fm.current_phase)})`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('state sync preserves current_phase_name from existing frontmatter when body lacks annotation', () => {
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
fs.writeFileSync(statePath, buildStateMdWithoutBodyAnnotations({ currentPhaseName: 'Implementation' }));
|
|
||||||
|
|
||||||
const syncResult = runGsdTools('state sync', tmpDir);
|
|
||||||
assert.ok(syncResult.success, `state sync failed: ${syncResult.error}`);
|
|
||||||
|
|
||||||
const jsonResult = runGsdTools('state json', tmpDir);
|
|
||||||
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
|
|
||||||
|
|
||||||
const fm = JSON.parse(jsonResult.output);
|
|
||||||
assert.strictEqual(
|
|
||||||
fm.current_phase_name,
|
|
||||||
'Implementation',
|
|
||||||
`current_phase_name must be preserved from existing frontmatter after sync (got: ${JSON.stringify(fm.current_phase_name)})`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('state sync preserves current_plan from existing frontmatter when body lacks annotation', () => {
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
fs.writeFileSync(statePath, buildStateMdWithoutBodyAnnotations({ currentPlan: 2 }));
|
|
||||||
|
|
||||||
const syncResult = runGsdTools('state sync', tmpDir);
|
|
||||||
assert.ok(syncResult.success, `state sync failed: ${syncResult.error}`);
|
|
||||||
|
|
||||||
const jsonResult = runGsdTools('state json', tmpDir);
|
|
||||||
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
|
|
||||||
|
|
||||||
const fm = JSON.parse(jsonResult.output);
|
|
||||||
assert.strictEqual(
|
|
||||||
fm.current_plan,
|
|
||||||
'2',
|
|
||||||
`current_plan must be preserved from existing frontmatter after sync (got: ${JSON.stringify(fm.current_plan)})`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('state update (resync:false) preserves curated progress from existing frontmatter when body lacks disk-scan data', () => {
|
|
||||||
// state update "Last Activity" calls readModifyWriteStateMd with resync:false.
|
|
||||||
// That path runs syncStateFrontmatter and then explicitly re-applies the
|
|
||||||
// pre-existing progress block (lines 1243-1253 of state.cts). The curated
|
|
||||||
// progress values must survive even though the phases dir is empty.
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
fs.writeFileSync(statePath, buildStateMdWithoutBodyAnnotations({ progressPercent: 42 }));
|
|
||||||
|
|
||||||
// Add body annotation for Last Activity so state update can find and replace it
|
|
||||||
const initial = fs.readFileSync(statePath, 'utf8');
|
|
||||||
fs.writeFileSync(statePath, initial.replace('Last Activity: 2026-01-01', 'Last Activity: 2026-01-01'));
|
|
||||||
|
|
||||||
const updateResult = runGsdTools(
|
|
||||||
['state', 'update', 'Last Activity', '2026-06-08'],
|
|
||||||
tmpDir,
|
|
||||||
);
|
|
||||||
assert.ok(updateResult.success, `state update failed: ${updateResult.error}`);
|
|
||||||
|
|
||||||
const jsonResult = runGsdTools('state json', tmpDir);
|
|
||||||
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
|
|
||||||
|
|
||||||
const fm = JSON.parse(jsonResult.output);
|
|
||||||
assert.ok(fm.progress, 'frontmatter must retain a progress block after body-only update');
|
|
||||||
// shouldPreserveExistingProgress: existing completed_plans (4) > derived (0 from empty disk)
|
|
||||||
// → curated block survives via cmdStateJson read-path fallback.
|
|
||||||
assert.strictEqual(
|
|
||||||
fm.progress.completed_plans,
|
|
||||||
4,
|
|
||||||
`progress.completed_plans must be preserved via shouldPreserveExistingProgress ` +
|
|
||||||
`(got: ${JSON.stringify(fm.progress?.completed_plans)})`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('state update field preserves current_phase frontmatter when body lacks annotation', () => {
|
|
||||||
// Trigger the write path via `state update` (which calls readModifyWriteStateMd
|
|
||||||
// with resync:true), confirming the fix covers every write path.
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
fs.writeFileSync(statePath, buildStateMdWithoutBodyAnnotations({ currentPhase: 7 }));
|
|
||||||
|
|
||||||
const updateResult = runGsdTools(
|
|
||||||
['state', 'update', 'Last Activity', '2026-06-08'],
|
|
||||||
tmpDir,
|
|
||||||
);
|
|
||||||
assert.ok(updateResult.success, `state update failed: ${updateResult.error}`);
|
|
||||||
|
|
||||||
const jsonResult = runGsdTools('state json', tmpDir);
|
|
||||||
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
|
|
||||||
|
|
||||||
const fm = JSON.parse(jsonResult.output);
|
|
||||||
assert.strictEqual(
|
|
||||||
fm.current_phase,
|
|
||||||
'7',
|
|
||||||
`current_phase must survive a state.update write (got: ${JSON.stringify(fm.current_phase)})`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('body annotation beats existing frontmatter when both are present', () => {
|
|
||||||
// When the body DOES carry the annotation, the derived value wins — we must
|
|
||||||
// not accidentally lock stale frontmatter in place.
|
|
||||||
// IMPORTANT: assert on the raw written STATE.md file (not just state json,
|
|
||||||
// which rebuilds from the body and would return body-derived values regardless
|
|
||||||
// of what syncStateFrontmatter wrote to disk).
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
// Frontmatter says phase 3; body says phase 5. Body should win.
|
|
||||||
fs.writeFileSync(statePath, [
|
|
||||||
'---',
|
|
||||||
'gsd_state_version: 1.0',
|
|
||||||
'current_phase: 3',
|
|
||||||
'current_phase_name: Old Phase',
|
|
||||||
'current_plan: 1',
|
|
||||||
'status: executing',
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'# GSD State',
|
|
||||||
'',
|
|
||||||
'## Configuration',
|
|
||||||
'Current Phase: 5',
|
|
||||||
'Current Phase Name: New Phase',
|
|
||||||
'Current Plan: 2',
|
|
||||||
'Status: Executing',
|
|
||||||
'Last Activity: 2026-01-01',
|
|
||||||
'',
|
|
||||||
].join('\n'));
|
|
||||||
|
|
||||||
const syncResult = runGsdTools('state sync', tmpDir);
|
|
||||||
assert.ok(syncResult.success, `state sync failed: ${syncResult.error}`);
|
|
||||||
|
|
||||||
// Assert on raw file: body-derived values must be written to frontmatter,
|
|
||||||
// not the stale existing values. This guards against a fallback that locks
|
|
||||||
// in stale data even when buildStateFrontmatter successfully derived values.
|
|
||||||
const writtenContent = fs.readFileSync(statePath, 'utf8');
|
|
||||||
const rawFm = parseFrontmatter(writtenContent);
|
|
||||||
assert.strictEqual(
|
|
||||||
rawFm.current_phase,
|
|
||||||
'5',
|
|
||||||
`body-derived current_phase (5) must be written to raw frontmatter (not stale 3), got: ${JSON.stringify(rawFm.current_phase)}`,
|
|
||||||
);
|
|
||||||
assert.strictEqual(
|
|
||||||
rawFm.current_phase_name,
|
|
||||||
'New Phase',
|
|
||||||
`body-derived current_phase_name must be written to raw frontmatter, got: ${JSON.stringify(rawFm.current_phase_name)}`,
|
|
||||||
);
|
|
||||||
assert.strictEqual(
|
|
||||||
rawFm.current_plan,
|
|
||||||
'2',
|
|
||||||
`body-derived current_plan must be written to raw frontmatter, got: ${JSON.stringify(rawFm.current_plan)}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('syncStateFrontmatter preserves progress from existing frontmatter when disk has no phases dir', () => {
|
|
||||||
// Directly exercises the !derivedFm['progress'] fallback in syncStateFrontmatter.
|
|
||||||
// Without a phases dir, buildStateFrontmatter returns no progress block at all
|
|
||||||
// (the existsSync guard at line ~927 short-circuits the disk scan). The
|
|
||||||
// existing frontmatter's progress must then survive the writeStateMd call.
|
|
||||||
// Use createTempDir (no phases dir) and set up .planning/ manually.
|
|
||||||
const dir = createTempDir('gsd-905-nophasesdir-');
|
|
||||||
try {
|
|
||||||
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
|
|
||||||
const statePath = path.join(dir, '.planning', 'STATE.md');
|
|
||||||
|
|
||||||
// Body has the "Current Phase:" annotation so cmdStateSync can proceed;
|
|
||||||
// the progress block is ONLY in frontmatter (no ROADMAP, no phases dir).
|
|
||||||
fs.writeFileSync(statePath, [
|
|
||||||
'---',
|
|
||||||
'gsd_state_version: 1.0',
|
|
||||||
'current_phase: 2',
|
|
||||||
'status: executing',
|
|
||||||
'progress:',
|
|
||||||
' total_phases: 4',
|
|
||||||
' completed_phases: 1',
|
|
||||||
' total_plans: 8',
|
|
||||||
' completed_plans: 3',
|
|
||||||
' percent: 38',
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'# GSD State',
|
|
||||||
'',
|
|
||||||
'## Configuration',
|
|
||||||
'Current Phase: 2',
|
|
||||||
'Status: Executing',
|
|
||||||
'Last Activity: 2026-01-01',
|
|
||||||
'',
|
|
||||||
].join('\n'));
|
|
||||||
|
|
||||||
// state update "Last Activity" → readModifyWriteStateMd (resync:true for
|
|
||||||
// Progress/Total Phases/Total Plans fields, but resync:false for Last Activity)
|
|
||||||
// This calls syncStateFrontmatter; without phases dir, buildStateFrontmatter
|
|
||||||
// produces no progress → !derivedFm['progress'] guard fires → existing preserved.
|
|
||||||
const updateResult = runGsdTools(
|
|
||||||
['state', 'update', 'Last Activity', '2026-06-08'],
|
|
||||||
dir,
|
|
||||||
);
|
|
||||||
assert.ok(updateResult.success, `state update failed: ${updateResult.error}`);
|
|
||||||
|
|
||||||
// Assert on the raw frontmatter file — cmdStateJson would apply
|
|
||||||
// shouldPreserveExistingProgress separately, so we must verify the on-disk state.
|
|
||||||
const written = fs.readFileSync(statePath, 'utf8');
|
|
||||||
const rawFm = parseFrontmatter(written);
|
|
||||||
|
|
||||||
// The progress block must be present in the written frontmatter.
|
|
||||||
// parseFrontmatter returns flat keys, so check the presence indicator.
|
|
||||||
assert.ok(
|
|
||||||
written.includes('progress:'),
|
|
||||||
'progress block must be preserved in raw frontmatter when disk has no phases dir',
|
|
||||||
);
|
|
||||||
// percent: 38 should survive (no disk scan to overwrite it)
|
|
||||||
assert.ok(
|
|
||||||
written.includes('percent: 38'),
|
|
||||||
`progress.percent: 38 must survive syncStateFrontmatter when no phases dir exists (raw: ${rawFm.progress})`,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
cleanup(dir);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Bug #1230 regression suite
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a STATE.md where:
|
|
||||||
* - frontmatter has an explicit status (e.g. 'completed') and optional stopped_at
|
|
||||||
* - body has a Status: field that is STALE relative to the frontmatter
|
|
||||||
* (e.g. "Verifying Phase 3" would derive 'verifying')
|
|
||||||
* A subsequent incidental write must NOT revert the hand-set frontmatter status.
|
|
||||||
*/
|
|
||||||
function buildStateMd1230({ fmStatus = 'completed', fmStoppedAt = null, bodyStatus = 'Verifying Phase 3', bodyStoppedAt = null } = {}) {
|
|
||||||
const fmLines = [
|
|
||||||
'---',
|
|
||||||
'gsd_state_version: 1.0',
|
|
||||||
`status: ${fmStatus}`,
|
|
||||||
];
|
|
||||||
if (fmStoppedAt) fmLines.push(`stopped_at: "${fmStoppedAt}"`);
|
|
||||||
fmLines.push('---');
|
|
||||||
|
|
||||||
const bodyLines = [
|
|
||||||
'',
|
|
||||||
'# GSD State',
|
|
||||||
'',
|
|
||||||
'## Configuration',
|
|
||||||
`Status: ${bodyStatus}`,
|
|
||||||
'Last Activity: 2026-01-01',
|
|
||||||
`Current Phase: 3`,
|
|
||||||
'',
|
|
||||||
'## Session',
|
|
||||||
'',
|
|
||||||
'**Last session:** 2026-01-01T00:00:00.000Z',
|
|
||||||
];
|
|
||||||
if (bodyStoppedAt) bodyLines.push(`**Stopped at:** ${bodyStoppedAt}`);
|
|
||||||
bodyLines.push('');
|
|
||||||
|
|
||||||
return [...fmLines, ...bodyLines].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('bug #1230: readModifyWriteStateMd preserves frontmatter status/stopped_at when write does not change body source field', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = createTempProject();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
// (a) CORE: record-session with stale body Status leaves frontmatter status: completed intact
|
|
||||||
test('(a) record-session does NOT revert frontmatter status: completed when body Status is unchanged', () => {
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
// frontmatter status: completed; body Status: Verifying Phase 3 (derives 'verifying')
|
|
||||||
fs.writeFileSync(statePath, buildStateMd1230({ fmStatus: 'completed', bodyStatus: 'Verifying Phase 3' }));
|
|
||||||
|
|
||||||
const result = runGsdTools(
|
|
||||||
['state', 'record-session', '--stopped-at', 'Phase 3 final review checkpoint'],
|
|
||||||
tmpDir,
|
|
||||||
);
|
|
||||||
assert.ok(result.success, `record-session failed: ${result.error}`);
|
|
||||||
|
|
||||||
// Assert on raw file frontmatter — not state json (which re-derives)
|
|
||||||
const written = fs.readFileSync(statePath, 'utf8');
|
|
||||||
const rawFm = parseFrontmatter(written);
|
|
||||||
assert.strictEqual(
|
|
||||||
rawFm.status,
|
|
||||||
'completed',
|
|
||||||
`frontmatter status must remain 'completed' after record-session (body Status unchanged); got: ${JSON.stringify(rawFm.status)}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// (b) add-decision (resync:true) with frontmatter status: completed, stale body → status preserved
|
|
||||||
test('(b) add-decision (resync:true) does NOT revert frontmatter status: completed when body Status unchanged', () => {
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
fs.writeFileSync(statePath, buildStateMd1230({ fmStatus: 'completed', bodyStatus: 'Verifying Phase 3' }));
|
|
||||||
|
|
||||||
const result = runGsdTools(
|
|
||||||
['state', 'add-decision', '--phase', '3', '--summary', 'Use Node 22'],
|
|
||||||
tmpDir,
|
|
||||||
);
|
|
||||||
assert.ok(result.success, `add-decision failed: ${result.error}`);
|
|
||||||
|
|
||||||
const written = fs.readFileSync(statePath, 'utf8');
|
|
||||||
const rawFm = parseFrontmatter(written);
|
|
||||||
assert.strictEqual(
|
|
||||||
rawFm.status,
|
|
||||||
'completed',
|
|
||||||
`frontmatter status must remain 'completed' after add-decision; got: ${JSON.stringify(rawFm.status)}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// (c) LEGITIMATE UPDATE NOT FROZEN: begin-phase changes body Status → frontmatter re-derived correctly.
|
|
||||||
//
|
|
||||||
// stateReplaceField(content, 'Status', ...) replaces the FIRST ^Status: match in the
|
|
||||||
// full content (frontmatter + body). When the frontmatter has NO 'status:' key, the
|
|
||||||
// match falls through to the body 'Status:' line, which IS changed. The delta then
|
|
||||||
// fires (preBodyStatus ≠ postBodyStatus), the preservation guard is skipped, and
|
|
||||||
// syncStateFrontmatter re-derives from the new body value as intended.
|
|
||||||
test('(c) begin-phase changes body Status → frontmatter status reflects new body-derived value', () => {
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
// Frontmatter intentionally has NO 'status:' key so stateReplaceField targets the body
|
|
||||||
// 'Status:' line. After the transform, body Status becomes "Executing Phase 3" →
|
|
||||||
// normalizeStateStatus → 'executing' must be written to frontmatter.
|
|
||||||
const content = [
|
|
||||||
'---',
|
|
||||||
'gsd_state_version: 1.0',
|
|
||||||
'milestone: v1.0',
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'# GSD State',
|
|
||||||
'',
|
|
||||||
'## Configuration',
|
|
||||||
'Status: Ready to execute',
|
|
||||||
'Last Activity: 2026-01-01',
|
|
||||||
'Current Phase: 2',
|
|
||||||
'Current Phase Name: Planning',
|
|
||||||
'Current Plan: 1',
|
|
||||||
'',
|
|
||||||
'## Current Position',
|
|
||||||
'',
|
|
||||||
'Phase: 2 (Planning) — READY',
|
|
||||||
'Plan: 1 of 1',
|
|
||||||
'Status: Ready to execute',
|
|
||||||
'Last activity: 2026-01-01 -- Phase 2 planning complete',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
fs.writeFileSync(statePath, content);
|
|
||||||
|
|
||||||
const result = runGsdTools(
|
|
||||||
['state', 'begin-phase', '--phase', '3', '--name', 'Execution'],
|
|
||||||
tmpDir,
|
|
||||||
);
|
|
||||||
assert.ok(result.success, `begin-phase failed: ${result.error}`);
|
|
||||||
|
|
||||||
const written = fs.readFileSync(statePath, 'utf8');
|
|
||||||
const rawFm = parseFrontmatter(written);
|
|
||||||
// begin-phase changed body Status to "Executing Phase 3" → delta fired → re-derived → 'executing'
|
|
||||||
assert.strictEqual(
|
|
||||||
rawFm.status,
|
|
||||||
'executing',
|
|
||||||
`frontmatter status must be updated to 'executing' when begin-phase changes body Status; got: ${JSON.stringify(rawFm.status)}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// (d) stopped_at: TRUE RED — frontmatter stopped_at preserved when body Stopped at differs
|
|
||||||
//
|
|
||||||
// Change C: this is a TRUE regression guard. Frontmatter stopped_at ("Phase 7 verified PASS")
|
|
||||||
// differs from the body ## Session "Stopped at:" value ("Phase 3 work"). The write operation
|
|
||||||
// (add-decision) does NOT touch the Session Stopped at line. The delta heuristic must detect
|
|
||||||
// that the Session Stopped at did NOT change (pre == post == "Phase 3 work") and therefore
|
|
||||||
// preserve the frontmatter value "Phase 7 verified PASS". Pre-fix code would REVERT to
|
|
||||||
// "Phase 3 work" (the body-derived value) — making this a genuine red.
|
|
||||||
test('(d) add-decision preserves frontmatter stopped_at when body Session Stopped at differs from frontmatter and is unchanged', () => {
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
const content = [
|
|
||||||
'---',
|
|
||||||
'gsd_state_version: 1.0',
|
|
||||||
'status: completed',
|
|
||||||
'stopped_at: "Phase 7 verified PASS"',
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'# GSD State',
|
|
||||||
'',
|
|
||||||
'## Configuration',
|
|
||||||
'Status: Phase 3 complete',
|
|
||||||
'Last Activity: 2026-01-01',
|
|
||||||
'Current Phase: 3',
|
|
||||||
'',
|
|
||||||
'## Session',
|
|
||||||
'',
|
|
||||||
'**Last session:** 2026-01-01T00:00:00.000Z',
|
|
||||||
// body Session Stopped at is STALE (different from frontmatter stopped_at)
|
|
||||||
'**Stopped at:** Phase 3 work',
|
|
||||||
'**Resume file:** None',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
fs.writeFileSync(statePath, content);
|
|
||||||
|
|
||||||
// add-decision does NOT touch ## Session Stopped at → pre and post body value identical
|
|
||||||
// ("Phase 3 work" unchanged) → delta fires → frontmatter "Phase 7 verified PASS" preserved.
|
|
||||||
const result = runGsdTools(
|
|
||||||
['state', 'add-decision', '--phase', '3', '--summary', 'Preserve stopped_at check'],
|
|
||||||
tmpDir,
|
|
||||||
);
|
|
||||||
assert.ok(result.success, `add-decision failed: ${result.error}`);
|
|
||||||
|
|
||||||
const written = fs.readFileSync(statePath, 'utf8');
|
|
||||||
const rawFm = parseFrontmatter(written);
|
|
||||||
assert.strictEqual(
|
|
||||||
rawFm.stopped_at,
|
|
||||||
'Phase 7 verified PASS',
|
|
||||||
`frontmatter stopped_at must be preserved ("Phase 7 verified PASS") when body Session Stopped at ` +
|
|
||||||
`is unchanged (stale "Phase 3 work"); got: ${JSON.stringify(rawFm.stopped_at)}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// (f) PRODUCTION-PATH: legitimate status transition is NOT frozen by the delta heuristic.
|
|
||||||
//
|
|
||||||
// Change A: prove that begin-phase on a STATE.md with inline "Status: Executing Phase 1"
|
|
||||||
// (the standard template format) correctly transitions frontmatter status from
|
|
||||||
// 'executing' to a new 'executing' value when the body Status CHANGES.
|
|
||||||
// More critically: also verify a complete-phase → frontmatter becomes 'completed'
|
|
||||||
// when the body Status field IS changed. This locks in that the delta heuristic
|
|
||||||
// re-derives correctly whenever the body's Status source field actually changes.
|
|
||||||
test('(f) begin-phase changes inline body Status → delta fires → frontmatter status updated (not frozen)', () => {
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
// Realistic STATE.md: frontmatter status: executing, body Status: Executing Phase 1 (inline format)
|
|
||||||
const content = [
|
|
||||||
'---',
|
|
||||||
'gsd_state_version: 1.0',
|
|
||||||
'status: executing',
|
|
||||||
'current_phase: 1',
|
|
||||||
'current_phase_name: Planning',
|
|
||||||
'current_plan: 1',
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'# GSD State',
|
|
||||||
'',
|
|
||||||
'## Configuration',
|
|
||||||
'Status: Executing Phase 1',
|
|
||||||
'Last Activity: 2026-01-01',
|
|
||||||
'Current Phase: 1',
|
|
||||||
'Current Phase Name: Planning',
|
|
||||||
'Current Plan: 1',
|
|
||||||
'Total Plans in Phase: 2',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
fs.writeFileSync(statePath, content);
|
|
||||||
|
|
||||||
// begin-phase 2 changes body Status from "Executing Phase 1" to "Executing Phase 2"
|
|
||||||
// → pre and post body Status differ → delta does NOT fire → syncStateFrontmatter
|
|
||||||
// re-derives status from new body value → frontmatter status must reflect 'executing'
|
|
||||||
// (still 'executing' after begin-phase 2 is a correct transition).
|
|
||||||
const beginResult = runGsdTools(
|
|
||||||
['state', 'begin-phase', '--phase', '2', '--name', 'Implementation'],
|
|
||||||
tmpDir,
|
|
||||||
);
|
|
||||||
assert.ok(beginResult.success, `state begin-phase failed: ${beginResult.error}`);
|
|
||||||
|
|
||||||
const written = fs.readFileSync(statePath, 'utf8');
|
|
||||||
const rawFm = parseFrontmatter(written);
|
|
||||||
// Frontmatter status must have been updated (not frozen at original 'executing' for phase 1).
|
|
||||||
// After begin-phase 2, body Status becomes "Executing Phase 2" → re-derived → still 'executing'
|
|
||||||
// but it must NOT be the stale body-derived value from before the transform; the key check is
|
|
||||||
// that the write completed successfully and status is a known valid value.
|
|
||||||
assert.ok(
|
|
||||||
rawFm.status === 'executing',
|
|
||||||
`frontmatter status must be 'executing' after begin-phase 2 (delta fires, re-derived); got: ${JSON.stringify(rawFm.status)}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Stronger check: if we then run a command that changes Status to a DIFFERENT value,
|
|
||||||
// the frontmatter MUST reflect the new body-derived status (not be frozen).
|
|
||||||
// Use state update to change Status to "Phase 2 complete" → derives 'completed'.
|
|
||||||
const updateResult = runGsdTools(
|
|
||||||
['state', 'update', 'Status', 'Phase 2 complete'],
|
|
||||||
tmpDir,
|
|
||||||
);
|
|
||||||
assert.ok(updateResult.success, `state update Status failed: ${updateResult.error}`);
|
|
||||||
|
|
||||||
const written2 = fs.readFileSync(statePath, 'utf8');
|
|
||||||
const rawFm2 = parseFrontmatter(written2);
|
|
||||||
assert.strictEqual(
|
|
||||||
rawFm2.status,
|
|
||||||
'completed',
|
|
||||||
`frontmatter status must be 'completed' after body Status → 'Phase 2 complete' (delta fires, re-derived); ` +
|
|
||||||
`got: ${JSON.stringify(rawFm2.status)}. The delta heuristic must NOT freeze status when the body field changes.`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// (e) milestone-switch uses platformWriteSync directly (not RMW) — check it still resets status correctly
|
|
||||||
test('(e) milestone-switch still resets frontmatter status to planning (uses writeStateMd path, not RMW)', () => {
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
fs.writeFileSync(statePath, buildStateMd1230({ fmStatus: 'completed', bodyStatus: 'Phase 3 complete' }));
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
||||||
'# Roadmap\n\n## v2.0 Next\n\n### Phase 4: Next steps\n',
|
|
||||||
'utf-8',
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = runGsdTools(
|
|
||||||
['state', 'milestone-switch', '--milestone', 'v2.0', '--name', 'Next'],
|
|
||||||
tmpDir,
|
|
||||||
);
|
|
||||||
assert.ok(result.success, `milestone-switch failed: ${result.error}`);
|
|
||||||
|
|
||||||
const written = fs.readFileSync(statePath, 'utf8');
|
|
||||||
const rawFm = parseFrontmatter(written);
|
|
||||||
assert.strictEqual(
|
|
||||||
rawFm.status,
|
|
||||||
'planning',
|
|
||||||
`milestone-switch must reset frontmatter status to 'planning'; got: ${JSON.stringify(rawFm.status)}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,898 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
/**
|
|
||||||
* Regression guard for bugs #948 and #944.
|
|
||||||
*
|
|
||||||
* #948 (data loss): a `state patch` whose fields all fail to match still
|
|
||||||
* rewrites STATE.md — bumping `last_updated`, resetting `milestone_name` to
|
|
||||||
* the template placeholder, and resurrecting a stale `stopped_at` from an
|
|
||||||
* old body `## Session` block (body-derived value overwrites a newer
|
|
||||||
* frontmatter value written by `record-session`).
|
|
||||||
*
|
|
||||||
* #944: `state record-session --stopped-at X --resume-file Y` silently
|
|
||||||
* drops the supplied values when the STATE.md body lacks the exact session
|
|
||||||
* labels the in-place replace expects, returning `{"recorded": false}` at
|
|
||||||
* exit 0 and only bumping `last_updated`.
|
|
||||||
*
|
|
||||||
* Shared root cause: `readModifyWriteStateMd` always writes STATE.md even
|
|
||||||
* when the transform produced no change, and `syncStateFrontmatter`
|
|
||||||
* re-derives frontmatter (including milestone_name / stopped_at) from the
|
|
||||||
* possibly-stale body on every write.
|
|
||||||
*
|
|
||||||
* Fixes:
|
|
||||||
* 1. No-op guard in `readModifyWriteStateMd`: when transform output ===
|
|
||||||
* input, skip the write entirely.
|
|
||||||
* 2. `syncStateFrontmatter` preserves existing `milestone_name` / `milestone`
|
|
||||||
* when the derived value is the template placeholder `'milestone'`.
|
|
||||||
* 3. `syncStateFrontmatter` prefers existing frontmatter `stopped_at` /
|
|
||||||
* `paused_at` over a body-derived value (frontmatter wins).
|
|
||||||
* 4. `cmdStateRecordSession` auto-creates a canonical `## Session` section
|
|
||||||
* when `--stopped-at` / `--resume-file` are supplied but no labels exist.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
|
|
||||||
const { runGsdTools, createTempProject, cleanup, parseFrontmatter } = require('./helpers.cjs');
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Fixture builders
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* STATE.md with:
|
|
||||||
* - real `milestone_name` in frontmatter (e.g. "My Real Milestone")
|
|
||||||
* - newer frontmatter `stopped_at` (written by a prior `record-session`)
|
|
||||||
* - stale `## Session` body section with an OLDER "Stopped at" line
|
|
||||||
*
|
|
||||||
* When a zero-match `state patch` runs on this file, NONE of these values
|
|
||||||
* should be disturbed — the file must be byte-identical afterward.
|
|
||||||
*/
|
|
||||||
function buildStateMdWithStaleSectionAndRealFrontmatter(opts) {
|
|
||||||
const {
|
|
||||||
milestoneName = 'My Real Milestone',
|
|
||||||
fmStoppedAt = 'Phase 3, Plan 2 — newer value',
|
|
||||||
bodyStoppedAt = 'Phase 1, Plan 1 — stale historical value',
|
|
||||||
lastUpdated = '2026-01-01T00:00:00.000Z',
|
|
||||||
} = opts || {};
|
|
||||||
|
|
||||||
return [
|
|
||||||
'---',
|
|
||||||
'gsd_state_version: 1.0',
|
|
||||||
'milestone: v2.0',
|
|
||||||
`milestone_name: ${milestoneName}`,
|
|
||||||
'status: executing',
|
|
||||||
`stopped_at: ${fmStoppedAt}`,
|
|
||||||
`last_updated: ${lastUpdated}`,
|
|
||||||
'progress:',
|
|
||||||
' total_phases: 5',
|
|
||||||
' completed_phases: 2',
|
|
||||||
' total_plans: 10',
|
|
||||||
' completed_plans: 4',
|
|
||||||
' percent: 40',
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'# GSD State',
|
|
||||||
'',
|
|
||||||
'## Current Position',
|
|
||||||
'',
|
|
||||||
'Status: Executing Phase 3',
|
|
||||||
'Last Activity: 2026-01-01',
|
|
||||||
'',
|
|
||||||
'## Session',
|
|
||||||
'',
|
|
||||||
`**Last session:** 2026-01-01T00:00:00.000Z`,
|
|
||||||
`**Stopped at:** ${bodyStoppedAt}`,
|
|
||||||
'**Resume file:** None',
|
|
||||||
'',
|
|
||||||
'## Accumulated Context',
|
|
||||||
'',
|
|
||||||
'### Decisions',
|
|
||||||
'',
|
|
||||||
'- [Phase 1]: Use Node 22',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* STATE.md with NO session section at all — no "## Session" heading,
|
|
||||||
* no Stopped at / Resume file labels. This is the #944 scenario.
|
|
||||||
*/
|
|
||||||
function buildStateMdWithoutSessionSection() {
|
|
||||||
return [
|
|
||||||
'---',
|
|
||||||
'gsd_state_version: 1.0',
|
|
||||||
'milestone: v1.0',
|
|
||||||
'milestone_name: Foundation',
|
|
||||||
'status: executing',
|
|
||||||
'last_updated: 2026-01-01T00:00:00.000Z',
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'# GSD State',
|
|
||||||
'',
|
|
||||||
'## Current Position',
|
|
||||||
'',
|
|
||||||
'Status: Executing Phase 1',
|
|
||||||
'Last Activity: 2026-01-01',
|
|
||||||
'',
|
|
||||||
'## Accumulated Context',
|
|
||||||
'',
|
|
||||||
'### Decisions',
|
|
||||||
'',
|
|
||||||
'- [Phase 1]: Use TypeScript',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* STATE.md with a canonical session section (the success path — must not regress).
|
|
||||||
*/
|
|
||||||
function buildStateMdWithCanonicalSessionSection() {
|
|
||||||
return [
|
|
||||||
'---',
|
|
||||||
'gsd_state_version: 1.0',
|
|
||||||
'milestone: v1.0',
|
|
||||||
'milestone_name: Foundation',
|
|
||||||
'status: executing',
|
|
||||||
'last_updated: 2026-01-01T00:00:00.000Z',
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'# GSD State',
|
|
||||||
'',
|
|
||||||
'## Session',
|
|
||||||
'',
|
|
||||||
'**Last session:** 2026-01-01T00:00:00.000Z',
|
|
||||||
'**Stopped at:** Phase 1, Plan 1',
|
|
||||||
'**Resume file:** None',
|
|
||||||
'',
|
|
||||||
'## Accumulated Context',
|
|
||||||
'',
|
|
||||||
'### Decisions',
|
|
||||||
'',
|
|
||||||
'- Use TypeScript',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Bug #948: zero-match patch must leave STATE.md byte-identical
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('#948: zero-match state patch must not rewrite STATE.md', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = createTempProject();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('STATE.md is byte-identical after a zero-match patch', () => {
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
const original = buildStateMdWithStaleSectionAndRealFrontmatter({});
|
|
||||||
fs.writeFileSync(statePath, original);
|
|
||||||
|
|
||||||
// Patch a field that does NOT exist in the file — zero matches expected.
|
|
||||||
const result = runGsdTools('state patch --NonExistentFieldXYZ "some value"', tmpDir);
|
|
||||||
assert.ok(result.success, `state patch should exit 0: ${result.error}`);
|
|
||||||
|
|
||||||
const patchOutput = JSON.parse(result.output);
|
|
||||||
assert.deepStrictEqual(patchOutput.updated, [], 'updated should be empty');
|
|
||||||
assert.ok(Array.isArray(patchOutput.failed), 'failed should be an array');
|
|
||||||
|
|
||||||
const after = fs.readFileSync(statePath, 'utf-8');
|
|
||||||
assert.strictEqual(after, original, 'STATE.md must be byte-identical after zero-match patch');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('milestone_name is preserved after zero-match patch (not reset to template placeholder)', () => {
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
const original = buildStateMdWithStaleSectionAndRealFrontmatter({
|
|
||||||
milestoneName: 'My Real Milestone',
|
|
||||||
});
|
|
||||||
fs.writeFileSync(statePath, original);
|
|
||||||
|
|
||||||
runGsdTools('state patch --NonExistentField "value"', tmpDir);
|
|
||||||
|
|
||||||
const after = fs.readFileSync(statePath, 'utf-8');
|
|
||||||
const fm = parseFrontmatter(after);
|
|
||||||
assert.strictEqual(fm['milestone_name'], 'My Real Milestone',
|
|
||||||
'milestone_name must not be reset to template placeholder by zero-match patch');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('stopped_at frontmatter value is preserved after zero-match patch (via byte-identity)', () => {
|
|
||||||
// The no-op guard prevents ANY rewrite when nothing changed, so the
|
|
||||||
// frontmatter stopped_at is preserved because the file is never touched.
|
|
||||||
// The stale body value cannot win because syncStateFrontmatter is never called.
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
const original = buildStateMdWithStaleSectionAndRealFrontmatter({
|
|
||||||
fmStoppedAt: 'Phase 3, Plan 2 — newer value',
|
|
||||||
bodyStoppedAt: 'Phase 1, Plan 1 — stale historical value',
|
|
||||||
});
|
|
||||||
fs.writeFileSync(statePath, original);
|
|
||||||
|
|
||||||
runGsdTools('state patch --NonExistentField "value"', tmpDir);
|
|
||||||
|
|
||||||
// The byte-identity test already covers this; this test confirms the key
|
|
||||||
// field specifically is intact.
|
|
||||||
const after = fs.readFileSync(statePath, 'utf-8');
|
|
||||||
assert.strictEqual(after, original,
|
|
||||||
'STATE.md must be byte-identical — stopped_at cannot be overwritten via a no-op patch');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('last_updated is not bumped by a zero-match patch', () => {
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
const original = buildStateMdWithStaleSectionAndRealFrontmatter({
|
|
||||||
lastUpdated: '2026-01-01T00:00:00.000Z',
|
|
||||||
});
|
|
||||||
fs.writeFileSync(statePath, original);
|
|
||||||
|
|
||||||
runGsdTools('state patch --NonExistentField "value"', tmpDir);
|
|
||||||
|
|
||||||
const after = fs.readFileSync(statePath, 'utf-8');
|
|
||||||
const fm = parseFrontmatter(after);
|
|
||||||
assert.strictEqual(fm['last_updated'], '2026-01-01T00:00:00.000Z',
|
|
||||||
'last_updated must not be bumped when no fields were changed');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a matching patch STILL updates STATE.md correctly (no regression)', () => {
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
const fixture = [
|
|
||||||
'---',
|
|
||||||
'gsd_state_version: 1.0',
|
|
||||||
'milestone: v1.0',
|
|
||||||
'milestone_name: Foundation',
|
|
||||||
'status: executing',
|
|
||||||
'last_updated: 2026-01-01T00:00:00.000Z',
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'# GSD State',
|
|
||||||
'',
|
|
||||||
'**Status:** In Progress',
|
|
||||||
'**Last Activity:** 2026-01-01',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
fs.writeFileSync(statePath, fixture);
|
|
||||||
|
|
||||||
const result = runGsdTools('state patch --Status "Phase complete — ready for verification"', tmpDir);
|
|
||||||
assert.ok(result.success, `state patch should succeed: ${result.error}`);
|
|
||||||
|
|
||||||
const patchOutput = JSON.parse(result.output);
|
|
||||||
assert.ok(patchOutput.updated.includes('Status'), 'Status should be in updated list');
|
|
||||||
|
|
||||||
const after = fs.readFileSync(statePath, 'utf-8');
|
|
||||||
assert.ok(after.includes('Phase complete — ready for verification'),
|
|
||||||
'matching patch should update the field');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Bug #948: syncStateFrontmatter — milestone_name placeholder preservation
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('#948: syncStateFrontmatter preserves milestone_name when derived is template placeholder', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = createTempProject();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('state sync preserves real milestone_name when disk yields only template placeholder', () => {
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
// Frontmatter has a real name, but no ROADMAP.md exists so getMilestoneInfo
|
|
||||||
// will fall back to the 'milestone' placeholder — must not overwrite.
|
|
||||||
const content = [
|
|
||||||
'---',
|
|
||||||
'gsd_state_version: 1.0',
|
|
||||||
'milestone: v2.5',
|
|
||||||
'milestone_name: Very Real Project Name',
|
|
||||||
'status: executing',
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'# GSD State',
|
|
||||||
'',
|
|
||||||
'Status: Executing Phase 1',
|
|
||||||
'Last Activity: 2026-01-01',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
fs.writeFileSync(statePath, content);
|
|
||||||
|
|
||||||
const result = runGsdTools('state sync', tmpDir);
|
|
||||||
assert.ok(result.success, `state sync failed: ${result.error}`);
|
|
||||||
|
|
||||||
const after = fs.readFileSync(statePath, 'utf-8');
|
|
||||||
const fm = parseFrontmatter(after);
|
|
||||||
assert.strictEqual(fm['milestone_name'], 'Very Real Project Name',
|
|
||||||
'milestone_name must not be reset to template placeholder by state sync');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('state sync runs successfully and preserves milestone_name (no corruption)', () => {
|
|
||||||
// state sync always rebuilds frontmatter from the body — the no-op guard
|
|
||||||
// applies to commands whose transform produces no change. state sync always
|
|
||||||
// writes because last_updated changes. This test verifies that a full sync
|
|
||||||
// cycle does not corrupt milestone_name when the placeholder is derived.
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
const content = [
|
|
||||||
'---',
|
|
||||||
'gsd_state_version: 1.0',
|
|
||||||
'milestone: v2.5',
|
|
||||||
'milestone_name: Very Real Project Name',
|
|
||||||
'status: executing',
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'# GSD State',
|
|
||||||
'',
|
|
||||||
'Status: Executing Phase 1',
|
|
||||||
'Last Activity: 2026-01-01',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
fs.writeFileSync(statePath, content);
|
|
||||||
|
|
||||||
const result = runGsdTools('state sync', tmpDir);
|
|
||||||
assert.ok(result.success, `state sync failed: ${result.error}`);
|
|
||||||
|
|
||||||
const after = fs.readFileSync(statePath, 'utf-8');
|
|
||||||
const fm = parseFrontmatter(after);
|
|
||||||
assert.strictEqual(fm['milestone_name'], 'Very Real Project Name',
|
|
||||||
'state sync must not reset milestone_name to template placeholder');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Bug #944: record-session with no session section must persist supplied values
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('#944: record-session persists values even when body lacks session labels', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = createTempProject();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('stopped-at and resume-file are present in STATE.md after record-session with no prior section', () => {
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
fs.writeFileSync(statePath, buildStateMdWithoutSessionSection());
|
|
||||||
|
|
||||||
const PINNED_MS = Date.parse('2026-06-09T12:00:00.000Z');
|
|
||||||
const result = runGsdTools(
|
|
||||||
'state record-session --stopped-at "Phase 2, Plan 3" --resume-file ".planning/phases/02/02-03-PLAN.md"',
|
|
||||||
tmpDir,
|
|
||||||
{ GSD_TEST_MODE: '1', GSD_NOW_MS: String(PINNED_MS) },
|
|
||||||
);
|
|
||||||
assert.ok(result.success, `state record-session should exit 0: ${result.error}`);
|
|
||||||
|
|
||||||
const output = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(output.recorded, true,
|
|
||||||
'recorded must be true when values were supplied and persisted');
|
|
||||||
assert.ok(!output.reason || output.reason !== 'No session fields found in STATE.md',
|
|
||||||
'must not return the silent no-op reason when values were supplied');
|
|
||||||
|
|
||||||
const after = fs.readFileSync(statePath, 'utf-8');
|
|
||||||
assert.ok(after.includes('Phase 2, Plan 3'),
|
|
||||||
'--stopped-at value must appear in STATE.md');
|
|
||||||
assert.ok(after.includes('.planning/phases/02/02-03-PLAN.md'),
|
|
||||||
'--resume-file value must appear in STATE.md');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('command does not silently no-op when values are supplied (recorded must not be false)', () => {
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
fs.writeFileSync(statePath, buildStateMdWithoutSessionSection());
|
|
||||||
|
|
||||||
const result = runGsdTools(
|
|
||||||
'state record-session --stopped-at "Phase 5, Plan 1"',
|
|
||||||
tmpDir,
|
|
||||||
);
|
|
||||||
assert.ok(result.success, `should exit 0: ${result.error}`);
|
|
||||||
|
|
||||||
const output = JSON.parse(result.output);
|
|
||||||
// The key contract: if values were supplied, recorded must be true.
|
|
||||||
assert.notStrictEqual(output.recorded, false,
|
|
||||||
'recorded must not be false when --stopped-at was explicitly supplied');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('STATE.md with non-canonical session labels still persists supplied values', () => {
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
// Session section exists but uses non-canonical label shapes (table, alternate caps)
|
|
||||||
const nonCanonical = [
|
|
||||||
'---',
|
|
||||||
'gsd_state_version: 1.0',
|
|
||||||
'milestone: v1.0',
|
|
||||||
'milestone_name: Foundation',
|
|
||||||
'status: executing',
|
|
||||||
'last_updated: 2026-01-01T00:00:00.000Z',
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'# GSD State',
|
|
||||||
'',
|
|
||||||
'## Session Info',
|
|
||||||
'',
|
|
||||||
'| Field | Value |',
|
|
||||||
'|-------|-------|',
|
|
||||||
'| Last Session | 2026-01-01 |',
|
|
||||||
'| Stopped Here | Phase 1, Plan 1 |',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
fs.writeFileSync(statePath, nonCanonical);
|
|
||||||
|
|
||||||
const PINNED_MS = Date.parse('2026-06-09T15:00:00.000Z');
|
|
||||||
const result = runGsdTools(
|
|
||||||
'state record-session --stopped-at "Phase 3, Plan 2" --resume-file "none.md"',
|
|
||||||
tmpDir,
|
|
||||||
{ GSD_TEST_MODE: '1', GSD_NOW_MS: String(PINNED_MS) },
|
|
||||||
);
|
|
||||||
assert.ok(result.success, `should exit 0: ${result.error}`);
|
|
||||||
|
|
||||||
const output = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(output.recorded, true,
|
|
||||||
'recorded must be true when values are persisted via auto-create fallback');
|
|
||||||
|
|
||||||
const after = fs.readFileSync(statePath, 'utf-8');
|
|
||||||
assert.ok(after.includes('Phase 3, Plan 2'),
|
|
||||||
'--stopped-at value must be present in STATE.md');
|
|
||||||
assert.ok(after.includes('none.md'),
|
|
||||||
'--resume-file value must be present in STATE.md');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('record-session with no args against a body-less file returns recorded:false (no regression)', () => {
|
|
||||||
// When NO values are supplied and no session fields can be found/updated,
|
|
||||||
// recorded:false is the correct behaviour — we only changed the contract
|
|
||||||
// when the caller supplies values.
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
fs.writeFileSync(statePath, buildStateMdWithoutSessionSection());
|
|
||||||
|
|
||||||
const result = runGsdTools('state record-session', tmpDir);
|
|
||||||
assert.ok(result.success, `should exit 0: ${result.error}`);
|
|
||||||
|
|
||||||
const output = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(output.recorded, false,
|
|
||||||
'recorded should still be false when no session fields exist AND no values were supplied');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('canonical session section still updates in place (no regression)', () => {
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
fs.writeFileSync(statePath, buildStateMdWithCanonicalSessionSection());
|
|
||||||
|
|
||||||
const PINNED_MS = Date.parse('2026-06-09T18:00:00.000Z');
|
|
||||||
const result = runGsdTools(
|
|
||||||
'state record-session --stopped-at "Phase 2, Plan 4" --resume-file ".planning/phases/02/02-04-PLAN.md"',
|
|
||||||
tmpDir,
|
|
||||||
{ GSD_TEST_MODE: '1', GSD_NOW_MS: String(PINNED_MS) },
|
|
||||||
);
|
|
||||||
assert.ok(result.success, `should exit 0: ${result.error}`);
|
|
||||||
|
|
||||||
const output = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(output.recorded, true, 'recorded should be true');
|
|
||||||
|
|
||||||
const after = fs.readFileSync(statePath, 'utf-8');
|
|
||||||
assert.ok(after.includes('Phase 2, Plan 4'), 'stopped-at should be updated');
|
|
||||||
assert.ok(after.includes('.planning/phases/02/02-04-PLAN.md'), 'resume-file should be updated');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Adversarial fixtures: malformed frontmatter, missing fields, CRLF
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('#948/#944: adversarial fixture variants', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = createTempProject();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('zero-match patch on CRLF STATE.md leaves file unchanged', () => {
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
// Build with CRLF line endings
|
|
||||||
const original = buildStateMdWithStaleSectionAndRealFrontmatter({}).replace(/\n/g, '\r\n');
|
|
||||||
fs.writeFileSync(statePath, original);
|
|
||||||
|
|
||||||
runGsdTools('state patch --NonExistentFieldXYZ "value"', tmpDir);
|
|
||||||
|
|
||||||
const after = fs.readFileSync(statePath, 'utf-8');
|
|
||||||
assert.strictEqual(after, original, 'CRLF file must be byte-identical after zero-match patch');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('zero-match patch on STATE.md with missing frontmatter fields does not corrupt', () => {
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
const minimal = [
|
|
||||||
'---',
|
|
||||||
'gsd_state_version: 1.0',
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'# GSD State',
|
|
||||||
'',
|
|
||||||
'**Status:** In Progress',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
fs.writeFileSync(statePath, minimal);
|
|
||||||
|
|
||||||
const result = runGsdTools('state patch --NonExistentField "value"', tmpDir);
|
|
||||||
assert.ok(result.success, `should exit 0: ${result.error}`);
|
|
||||||
|
|
||||||
const patchOutput = JSON.parse(result.output);
|
|
||||||
assert.deepStrictEqual(patchOutput.updated, [], 'no fields should be updated');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('record-session with empty body still records when values supplied', () => {
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
// Body is entirely empty (only frontmatter)
|
|
||||||
const emptyBody = [
|
|
||||||
'---',
|
|
||||||
'gsd_state_version: 1.0',
|
|
||||||
'status: planning',
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
fs.writeFileSync(statePath, emptyBody);
|
|
||||||
|
|
||||||
const result = runGsdTools(
|
|
||||||
'state record-session --stopped-at "Phase 1, Plan 1"',
|
|
||||||
tmpDir,
|
|
||||||
);
|
|
||||||
assert.ok(result.success, `should exit 0: ${result.error}`);
|
|
||||||
|
|
||||||
const output = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(output.recorded, true,
|
|
||||||
'should persist even into a body-less STATE.md');
|
|
||||||
|
|
||||||
const after = fs.readFileSync(statePath, 'utf-8');
|
|
||||||
assert.ok(after.includes('Phase 1, Plan 1'),
|
|
||||||
'--stopped-at value must appear in STATE.md');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Adversarial review findings: in-place update for existing ## Session heading
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('#944 adversarial: existing ## Session heading must be updated in place, not duplicated', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = createTempProject();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* HIGH finding: when a `## Session` heading already exists but uses
|
|
||||||
* non-canonical rows (e.g. a markdown table), the DWIM code was appending
|
|
||||||
* a second `## Session` block instead of normalizing the existing one.
|
|
||||||
* buildStateFrontmatter / cmdStateSnapshot both read only the FIRST match,
|
|
||||||
* so the newly-written Stopped at / Resume file end up in an ignored block.
|
|
||||||
*/
|
|
||||||
test('record-session with existing non-canonical ## Session block: exactly one ## Session block afterward', () => {
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
const nonCanonicalWithHeading = [
|
|
||||||
'---',
|
|
||||||
'gsd_state_version: 1.0',
|
|
||||||
'milestone: v1.0',
|
|
||||||
'milestone_name: Foundation',
|
|
||||||
'status: executing',
|
|
||||||
'last_updated: 2026-01-01T00:00:00.000Z',
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'# GSD State',
|
|
||||||
'',
|
|
||||||
'## Session',
|
|
||||||
'',
|
|
||||||
'| Field | Value |',
|
|
||||||
'|-------|-------|',
|
|
||||||
'| Last Session | 2026-01-01 |',
|
|
||||||
'| Stopped Here | Phase 1, Plan 1 |',
|
|
||||||
'',
|
|
||||||
'## Accumulated Context',
|
|
||||||
'',
|
|
||||||
'- Decision: use TypeScript',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
fs.writeFileSync(statePath, nonCanonicalWithHeading);
|
|
||||||
|
|
||||||
const PINNED_MS = Date.parse('2026-06-09T20:00:00.000Z');
|
|
||||||
const result = runGsdTools(
|
|
||||||
'state record-session --stopped-at "Phase 4, Plan 2" --resume-file "resume.md"',
|
|
||||||
tmpDir,
|
|
||||||
{ GSD_TEST_MODE: '1', GSD_NOW_MS: String(PINNED_MS) },
|
|
||||||
);
|
|
||||||
assert.ok(result.success, `record-session should exit 0: ${result.error}`);
|
|
||||||
|
|
||||||
const after = fs.readFileSync(statePath, 'utf-8');
|
|
||||||
|
|
||||||
// (a) exactly ONE ## Session block — no duplicate
|
|
||||||
const sessionHeadingCount = (after.match(/^## Session\s*$/gm) || []).length;
|
|
||||||
assert.strictEqual(sessionHeadingCount, 1,
|
|
||||||
'exactly ONE ## Session block must exist after record-session (no duplicate appended)');
|
|
||||||
|
|
||||||
// (b) supplied values are present in the file
|
|
||||||
assert.ok(after.includes('Phase 4, Plan 2'),
|
|
||||||
'--stopped-at value must be present in STATE.md');
|
|
||||||
assert.ok(after.includes('resume.md'),
|
|
||||||
'--resume-file value must be present in STATE.md');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('record-session with existing non-canonical ## Session block: state-snapshot sees supplied stopped_at', () => {
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
const nonCanonicalWithHeading = [
|
|
||||||
'---',
|
|
||||||
'gsd_state_version: 1.0',
|
|
||||||
'milestone: v1.0',
|
|
||||||
'milestone_name: Foundation',
|
|
||||||
'status: executing',
|
|
||||||
'last_updated: 2026-01-01T00:00:00.000Z',
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'# GSD State',
|
|
||||||
'',
|
|
||||||
'## Session',
|
|
||||||
'',
|
|
||||||
'| Field | Value |',
|
|
||||||
'|-------|-------|',
|
|
||||||
'| Last Session | 2026-01-01 |',
|
|
||||||
'| Stopped Here | Phase 1, Plan 1 |',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
fs.writeFileSync(statePath, nonCanonicalWithHeading);
|
|
||||||
|
|
||||||
const PINNED_MS = Date.parse('2026-06-09T20:30:00.000Z');
|
|
||||||
runGsdTools(
|
|
||||||
'state record-session --stopped-at "Phase 4, Plan 2" --resume-file "resume.md"',
|
|
||||||
tmpDir,
|
|
||||||
{ GSD_TEST_MODE: '1', GSD_NOW_MS: String(PINNED_MS) },
|
|
||||||
);
|
|
||||||
|
|
||||||
// (c) state-snapshot must see the written stopped_at in the session block
|
|
||||||
// (via buildStateFrontmatter frontmatter OR body Session section, first match)
|
|
||||||
const snapshotResult = runGsdTools('state-snapshot', tmpDir);
|
|
||||||
assert.ok(snapshotResult.success, `state-snapshot should exit 0: ${snapshotResult.error}`);
|
|
||||||
const snapshot = JSON.parse(snapshotResult.output);
|
|
||||||
assert.strictEqual(
|
|
||||||
snapshot.session && snapshot.session.stopped_at,
|
|
||||||
'Phase 4, Plan 2',
|
|
||||||
`state-snapshot session.stopped_at must reflect "Phase 4, Plan 2", got: ${JSON.stringify(snapshot.session)}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* LOW finding: auto-created scaffold writes `**Last session:**` but
|
|
||||||
* cmdStateSnapshot only matched `**Last Date:**`, so session.last_date
|
|
||||||
* was null after auto-create despite a valid timestamp being written.
|
|
||||||
* Fix: teach the snapshot parser to also accept `**Last session:**`.
|
|
||||||
*/
|
|
||||||
test('state-snapshot returns non-null session.last_date after auto-create on body-less file', () => {
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
fs.writeFileSync(statePath, buildStateMdWithoutSessionSection());
|
|
||||||
|
|
||||||
const PINNED_MS = Date.parse('2026-06-09T21:00:00.000Z');
|
|
||||||
const recResult = runGsdTools(
|
|
||||||
'state record-session --stopped-at "Phase 1, Plan 1"',
|
|
||||||
tmpDir,
|
|
||||||
{ GSD_TEST_MODE: '1', GSD_NOW_MS: String(PINNED_MS) },
|
|
||||||
);
|
|
||||||
assert.ok(recResult.success, `record-session should exit 0: ${recResult.error}`);
|
|
||||||
|
|
||||||
const snapshotResult = runGsdTools('state-snapshot', tmpDir);
|
|
||||||
assert.ok(snapshotResult.success, `state-snapshot should exit 0: ${snapshotResult.error}`);
|
|
||||||
const snapshot = JSON.parse(snapshotResult.output);
|
|
||||||
assert.notStrictEqual(
|
|
||||||
snapshot.session && snapshot.session.last_date,
|
|
||||||
null,
|
|
||||||
`state-snapshot session.last_date must not be null after auto-create; got: ${JSON.stringify(snapshot.session)}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Bug #1101: record-session on a `## Session Continuity` bootstrap section must
|
|
||||||
// update IN PLACE, not append a duplicate `## Session` block.
|
|
||||||
//
|
|
||||||
// The reported symptom (recorded:false + frontmatter still mutated) is already
|
|
||||||
// fixed by #944/#948. The residual: the DWIM auto-create recognised only the
|
|
||||||
// canonical `## Session` heading, so a bootstrap `## Session Continuity` section
|
|
||||||
// (workstream.cts, gsd2-import.cts, templates/state.md) fell through to the
|
|
||||||
// append branch and produced a SECOND `## Session` block. The fix inserts the
|
|
||||||
// missing canonical fields into the existing `## Session Continuity` section,
|
|
||||||
// preserving the heading and any prose, and teaches the snapshot / frontmatter
|
|
||||||
// readers to recognise that heading.
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('#1101: record-session updates ## Session Continuity in place (no duplicate block)', () => {
|
|
||||||
let tmpDir;
|
|
||||||
beforeEach(() => { tmpDir = createTempProject(); });
|
|
||||||
afterEach(() => { cleanup(tmpDir); });
|
|
||||||
|
|
||||||
/** Workstream bootstrap shape: bold Stopped At/Resume File, no Last session. */
|
|
||||||
function buildWorkstreamContinuity() {
|
|
||||||
return [
|
|
||||||
'---',
|
|
||||||
'gsd_state_version: 1.0',
|
|
||||||
'milestone: v1.0',
|
|
||||||
'milestone_name: Foundation',
|
|
||||||
'status: executing',
|
|
||||||
'last_updated: 2026-01-01T00:00:00.000Z',
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'# State: example',
|
|
||||||
'',
|
|
||||||
'## Session Continuity',
|
|
||||||
'**Stopped At:** N/A',
|
|
||||||
'**Resume File:** None',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
test('workstream Session Continuity is updated in place — no duplicate ## Session appended', () => {
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
fs.writeFileSync(statePath, buildWorkstreamContinuity());
|
|
||||||
|
|
||||||
const PINNED_MS = Date.parse('2026-06-12T12:00:00.000Z');
|
|
||||||
const result = runGsdTools(
|
|
||||||
'state record-session --stopped-at "Phase 1 context gathered" --resume-file ".planning/phases/01/01-CONTEXT.md"',
|
|
||||||
tmpDir,
|
|
||||||
{ GSD_TEST_MODE: '1', GSD_NOW_MS: String(PINNED_MS) },
|
|
||||||
);
|
|
||||||
assert.ok(result.success, `record-session should exit 0: ${result.error}`);
|
|
||||||
const out = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(out.recorded, true, 'recorded must be true when values are supplied');
|
|
||||||
|
|
||||||
const after = fs.readFileSync(statePath, 'utf-8');
|
|
||||||
// No duplicate bare `## Session` heading appended (the only heading stays
|
|
||||||
// `## Session Continuity`).
|
|
||||||
assert.ok(!/^## Session[ \t]*$/m.test(after),
|
|
||||||
`must not append a duplicate bare "## Session" block; got:\n${after}`);
|
|
||||||
assert.strictEqual((after.match(/^## Session\b/gm) || []).length, 1,
|
|
||||||
`exactly one Session-family heading must remain; got:\n${after}`);
|
|
||||||
// Missing canonical field inserted; supplied values present.
|
|
||||||
assert.ok(after.includes('**Last session:**'), 'Last session field must be inserted');
|
|
||||||
assert.ok(after.includes('Phase 1 context gathered'), '--stopped-at value must be present');
|
|
||||||
assert.ok(after.includes('.planning/phases/01/01-CONTEXT.md'), '--resume-file value must be present');
|
|
||||||
// The frontmatter reader recognises `## Session Continuity` and derives stopped_at.
|
|
||||||
const fm = parseFrontmatter(after);
|
|
||||||
assert.strictEqual(fm.stopped_at, 'Phase 1 context gathered',
|
|
||||||
'frontmatter stopped_at must be derived from the ## Session Continuity section');
|
|
||||||
// The cmdStateSnapshot reader (separate code path) must also resolve it.
|
|
||||||
const snap = runGsdTools('state-snapshot', tmpDir);
|
|
||||||
assert.ok(snap.success, `state-snapshot should exit 0: ${snap.error}`);
|
|
||||||
const snapshot = JSON.parse(snap.output);
|
|
||||||
assert.strictEqual(snapshot.session.stopped_at, 'Phase 1 context gathered',
|
|
||||||
'state-snapshot must read stopped_at from the ## Session Continuity section');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('prose under ## Session Continuity is preserved (no data loss)', () => {
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
const withProse = [
|
|
||||||
'---',
|
|
||||||
'gsd_state_version: 1.0',
|
|
||||||
'milestone: v1.0',
|
|
||||||
'milestone_name: Foundation',
|
|
||||||
'status: executing',
|
|
||||||
'last_updated: 2026-01-01T00:00:00.000Z',
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'# State: example',
|
|
||||||
'',
|
|
||||||
'## Session Continuity',
|
|
||||||
'',
|
|
||||||
'**Next recommended action:** keep-me-intact',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
fs.writeFileSync(statePath, withProse);
|
|
||||||
|
|
||||||
const PINNED_MS = Date.parse('2026-06-12T13:00:00.000Z');
|
|
||||||
const result = runGsdTools(
|
|
||||||
'state record-session --stopped-at "Phase 2 done" --resume-file "none.md"',
|
|
||||||
tmpDir,
|
|
||||||
{ GSD_TEST_MODE: '1', GSD_NOW_MS: String(PINNED_MS) },
|
|
||||||
);
|
|
||||||
assert.ok(result.success, `record-session should exit 0: ${result.error}`);
|
|
||||||
const out = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(out.recorded, true, 'recorded must be true');
|
|
||||||
|
|
||||||
const after = fs.readFileSync(statePath, 'utf-8');
|
|
||||||
assert.ok(after.includes('**Next recommended action:** keep-me-intact'),
|
|
||||||
`existing prose must be preserved (no data loss); got:\n${after}`);
|
|
||||||
assert.ok(!/^## Session[ \t]*$/m.test(after),
|
|
||||||
'must not append a duplicate bare "## Session" block');
|
|
||||||
assert.ok(after.includes('Phase 2 done'), '--stopped-at value must be present');
|
|
||||||
const fm = parseFrontmatter(after);
|
|
||||||
assert.strictEqual(fm.stopped_at, 'Phase 2 done',
|
|
||||||
'frontmatter stopped_at must be derived from the ## Session Continuity section');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('canonical ## Session block path is unchanged (no regression)', () => {
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
fs.writeFileSync(statePath, buildStateMdWithCanonicalSessionSection());
|
|
||||||
|
|
||||||
const PINNED_MS = Date.parse('2026-06-12T14:00:00.000Z');
|
|
||||||
const result = runGsdTools(
|
|
||||||
'state record-session --stopped-at "Phase 9, Plan 9" --resume-file "r.md"',
|
|
||||||
tmpDir,
|
|
||||||
{ GSD_TEST_MODE: '1', GSD_NOW_MS: String(PINNED_MS) },
|
|
||||||
);
|
|
||||||
assert.ok(result.success, `record-session should exit 0: ${result.error}`);
|
|
||||||
const after = fs.readFileSync(statePath, 'utf-8');
|
|
||||||
assert.strictEqual((after.match(/^## Session\b/gm) || []).length, 1,
|
|
||||||
'canonical ## Session block must remain single');
|
|
||||||
assert.ok(after.includes('Phase 9, Plan 9'), 'stopped-at value updated in canonical block');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('legacy duplicate file: reader PREFERS canonical ## Session over ## Session Continuity (F1)', () => {
|
|
||||||
// A file created by the OLD bug: a stale `## Session Continuity` first, then an
|
|
||||||
// appended fresh `## Session`. The snapshot reader must read the canonical
|
|
||||||
// `## Session` (fresh), matching the writer, not the stale Continuity block.
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
const duplicate = [
|
|
||||||
'---',
|
|
||||||
'gsd_state_version: 1.0',
|
|
||||||
'milestone: v1.0',
|
|
||||||
'milestone_name: Foundation',
|
|
||||||
'status: executing',
|
|
||||||
'last_updated: 2026-01-01T00:00:00.000Z',
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'# State: example',
|
|
||||||
'',
|
|
||||||
'## Session Continuity',
|
|
||||||
'**Stopped At:** STALE-continuity-value',
|
|
||||||
'**Resume File:** None',
|
|
||||||
'',
|
|
||||||
'## Session',
|
|
||||||
'',
|
|
||||||
'**Last session:** 2026-06-12T10:00:00.000Z',
|
|
||||||
'**Stopped at:** FRESH-canonical-value',
|
|
||||||
'**Resume file:** r.md',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
fs.writeFileSync(statePath, duplicate);
|
|
||||||
|
|
||||||
const snap = runGsdTools('state-snapshot', tmpDir);
|
|
||||||
assert.ok(snap.success, `state-snapshot should exit 0: ${snap.error}`);
|
|
||||||
const snapshot = JSON.parse(snap.output);
|
|
||||||
assert.strictEqual(snapshot.session.stopped_at, 'FRESH-canonical-value',
|
|
||||||
'reader must prefer the canonical ## Session block over the stale ## Session Continuity');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('h3 ### Session Continuity is NOT read as the session section (F4)', () => {
|
|
||||||
// The reader is line-anchored to `^## `, so an h3 subsection must not be picked
|
|
||||||
// up as the session section.
|
|
||||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
||||||
const h3Only = [
|
|
||||||
'---',
|
|
||||||
'gsd_state_version: 1.0',
|
|
||||||
'milestone: v1.0',
|
|
||||||
'milestone_name: Foundation',
|
|
||||||
'status: executing',
|
|
||||||
'last_updated: 2026-01-01T00:00:00.000Z',
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'# State: example',
|
|
||||||
'',
|
|
||||||
'### Session Continuity',
|
|
||||||
'**Last session:** 2026-06-12T10:00:00.000Z',
|
|
||||||
'**Stopped at:** h3-should-not-be-session',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
fs.writeFileSync(statePath, h3Only);
|
|
||||||
|
|
||||||
const snap = runGsdTools('state-snapshot', tmpDir);
|
|
||||||
assert.ok(snap.success, `state-snapshot should exit 0: ${snap.error}`);
|
|
||||||
const snapshot = JSON.parse(snap.output);
|
|
||||||
assert.strictEqual(snapshot.session.last_date, null,
|
|
||||||
'an h3 ### Session Continuity must not be treated as the ## Session section');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,315 +0,0 @@
|
|||||||
/**
|
|
||||||
* Regression test for bug #967: verify key-links reads from:/to: as literal
|
|
||||||
* relative file paths; the reference docs wrongly implied component/endpoint
|
|
||||||
* values were valid. Fix direction: author-strict — docs corrected to match code.
|
|
||||||
*
|
|
||||||
* Contract pinned here:
|
|
||||||
* 1. from: must be a relative file path; pattern: is evaluated against its content.
|
|
||||||
* 2. from: pointing to a non-existent file → verified:false, detail "Source file not found".
|
|
||||||
* 3. docs/reference/plan-md.md reference example uses a file path for to: (NOT /api/feed).
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
|
||||||
|
|
||||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function writePlanWithKeyLinks(tmpDir, keyLinksYaml, opts) {
|
|
||||||
// parseMustHavesBlock expects 4-space indent for block name, 6-space for items
|
|
||||||
const wave = (opts && opts.wave != null) ? opts.wave : 1;
|
|
||||||
const filesModified = (opts && opts.filesModified) ? opts.filesModified : ['src/a.js'];
|
|
||||||
const filesModifiedYaml = filesModified.length === 1
|
|
||||||
? `[${filesModified[0]}]`
|
|
||||||
: `[${filesModified.join(', ')}]`;
|
|
||||||
const content = [
|
|
||||||
'---',
|
|
||||||
'phase: 01-test',
|
|
||||||
'plan: 01',
|
|
||||||
'type: execute',
|
|
||||||
`wave: ${wave}`,
|
|
||||||
'depends_on: []',
|
|
||||||
`files_modified: ${filesModifiedYaml}`,
|
|
||||||
'autonomous: true',
|
|
||||||
'must_haves:',
|
|
||||||
' key_links:',
|
|
||||||
...keyLinksYaml.map(line => ` ${line}`),
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'<tasks>',
|
|
||||||
'<task type="auto">',
|
|
||||||
' <name>Task 1: Do thing</name>',
|
|
||||||
' <files>src/a.js</files>',
|
|
||||||
' <action>Do it</action>',
|
|
||||||
' <verify><automated>echo ok</automated></verify>',
|
|
||||||
' <done>Done</done>',
|
|
||||||
'</task>',
|
|
||||||
'</tasks>',
|
|
||||||
].join('\n');
|
|
||||||
const planPath = path.join(tmpDir, '.planning', 'phases', '01-test', '01-01-PLAN.md');
|
|
||||||
fs.mkdirSync(path.dirname(planPath), { recursive: true });
|
|
||||||
fs.writeFileSync(planPath, content);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Write an additional plan file in the same phase directory with specific
|
|
||||||
* wave + files_modified (no key_links, just declaring future artifacts).
|
|
||||||
*/
|
|
||||||
function writeCompanionPlan(tmpDir, planFileName, wave, filesModified) {
|
|
||||||
const filesModifiedYaml = `[${filesModified.join(', ')}]`;
|
|
||||||
const content = [
|
|
||||||
'---',
|
|
||||||
'phase: 01-test',
|
|
||||||
'plan: 02',
|
|
||||||
'type: execute',
|
|
||||||
`wave: ${wave}`,
|
|
||||||
'depends_on: []',
|
|
||||||
`files_modified: ${filesModifiedYaml}`,
|
|
||||||
'autonomous: true',
|
|
||||||
'must_haves:',
|
|
||||||
' key_links: []',
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'<tasks>',
|
|
||||||
'<task type="auto">',
|
|
||||||
' <name>Task 2: Create file</name>',
|
|
||||||
' <files>src/b.js</files>',
|
|
||||||
' <action>Create it</action>',
|
|
||||||
' <verify><automated>echo ok</automated></verify>',
|
|
||||||
' <done>Done</done>',
|
|
||||||
'</task>',
|
|
||||||
'</tasks>',
|
|
||||||
].join('\n');
|
|
||||||
const planPath = path.join(tmpDir, '.planning', 'phases', '01-test', planFileName);
|
|
||||||
fs.writeFileSync(planPath, content);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('bug-967 verify key-links strict file-path contract', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = createTempProject();
|
|
||||||
fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 1. Happy path: from: is a real file path and pattern: matches ──────────
|
|
||||||
test('verified:true when from: is a relative file path and pattern: matches', () => {
|
|
||||||
writePlanWithKeyLinks(tmpDir, [
|
|
||||||
'- from: "src/component.js"',
|
|
||||||
' to: "src/api/feed.js"',
|
|
||||||
' via: "fetch in useEffect"',
|
|
||||||
' pattern: "fetch.*api/feed"',
|
|
||||||
]);
|
|
||||||
// Create the source file containing the pattern
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(tmpDir, 'src', 'component.js'),
|
|
||||||
"fetch('/api/feed').then(r => r.json());\n",
|
|
||||||
);
|
|
||||||
// Create the target file too (not strictly needed for this path, but realistic)
|
|
||||||
fs.mkdirSync(path.join(tmpDir, 'src', 'api'), { recursive: true });
|
|
||||||
fs.writeFileSync(path.join(tmpDir, 'src', 'api', 'feed.js'), 'module.exports = {};\n');
|
|
||||||
|
|
||||||
const result = runGsdTools(
|
|
||||||
'verify key-links .planning/phases/01-test/01-01-PLAN.md',
|
|
||||||
tmpDir,
|
|
||||||
);
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
||||||
|
|
||||||
const output = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(
|
|
||||||
output.all_verified,
|
|
||||||
true,
|
|
||||||
`Expected all_verified:true (file-path from: + matching pattern:). Got: ${JSON.stringify(output)}`,
|
|
||||||
);
|
|
||||||
assert.strictEqual(output.links[0].verified, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 2. Contract: missing source file → verified:false, explicit detail ─────
|
|
||||||
test('verified:false with "Source file not found" detail when from: file does not exist', () => {
|
|
||||||
writePlanWithKeyLinks(tmpDir, [
|
|
||||||
'- from: "src/missing-file.js"',
|
|
||||||
' to: "src/api/feed.js"',
|
|
||||||
' via: "fetch in useEffect"',
|
|
||||||
' pattern: "fetch.*api/feed"',
|
|
||||||
]);
|
|
||||||
// Deliberately do NOT create src/missing-file.js
|
|
||||||
|
|
||||||
const result = runGsdTools(
|
|
||||||
'verify key-links .planning/phases/01-test/01-01-PLAN.md',
|
|
||||||
tmpDir,
|
|
||||||
);
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
||||||
|
|
||||||
const output = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(
|
|
||||||
output.links[0].verified,
|
|
||||||
false,
|
|
||||||
`Expected verified:false for absent source file. Got: ${JSON.stringify(output.links[0])}`,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
output.links[0].detail.includes('Source file not found'),
|
|
||||||
`Expected detail to include "Source file not found". Got: "${output.links[0].detail}"`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 3. Regression #1202: missing from: file promised by a same-wave plan → pending:true ──
|
|
||||||
//
|
|
||||||
// A from: file absent on disk but listed in files_modified of another plan at
|
|
||||||
// the same wave must be reported pending:true (not verified:false) and must NOT
|
|
||||||
// count against the all_verified gate.
|
|
||||||
//
|
|
||||||
// This test MUST FAIL before the fix is applied (the gate hard-fails today).
|
|
||||||
test('pending:true and all_verified:true when from: file is promised by a same-wave plan', () => {
|
|
||||||
// Plan under test is wave 2; it references src/future-artifact.js which does not
|
|
||||||
// exist on disk yet.
|
|
||||||
writePlanWithKeyLinks(tmpDir, [
|
|
||||||
'- from: "src/future-artifact.js"',
|
|
||||||
' to: "src/consumer.js"',
|
|
||||||
' via: "requires future-artifact"',
|
|
||||||
' pattern: "future-artifact"',
|
|
||||||
], { wave: 2, filesModified: ['src/consumer.js'] });
|
|
||||||
|
|
||||||
// A companion plan also at wave 2 declares src/future-artifact.js in files_modified
|
|
||||||
writeCompanionPlan(tmpDir, '01-02-PLAN.md', 2, ['src/future-artifact.js']);
|
|
||||||
|
|
||||||
// Do NOT create src/future-artifact.js on disk — it is a planned future file
|
|
||||||
|
|
||||||
const result = runGsdTools(
|
|
||||||
'verify key-links .planning/phases/01-test/01-01-PLAN.md',
|
|
||||||
tmpDir,
|
|
||||||
);
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
||||||
|
|
||||||
const out = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(
|
|
||||||
out.links[0].pending,
|
|
||||||
true,
|
|
||||||
`Expected pending:true for a from: file promised by a same-wave plan. Got: ${JSON.stringify(out.links[0])}`,
|
|
||||||
);
|
|
||||||
assert.strictEqual(
|
|
||||||
out.all_verified,
|
|
||||||
true,
|
|
||||||
`Expected all_verified:true (pending links should not fail the gate). Got: ${JSON.stringify(out)}`,
|
|
||||||
);
|
|
||||||
assert.strictEqual(
|
|
||||||
out.links[0].verified,
|
|
||||||
false,
|
|
||||||
`Expected verified:false (file is not yet verified — just pending). Got: ${JSON.stringify(out.links[0])}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 4. Regression #1202: missing from: file promised by a LATER-wave plan → pending:true ──
|
|
||||||
test('pending:true and all_verified:true when from: file is promised by a later-wave plan', () => {
|
|
||||||
// Plan under test is wave 1; companion plan is wave 3 (later wave promises the file)
|
|
||||||
writePlanWithKeyLinks(tmpDir, [
|
|
||||||
'- from: "src/later-artifact.js"',
|
|
||||||
' to: "src/consumer.js"',
|
|
||||||
' via: "later wave dependency"',
|
|
||||||
], { wave: 1, filesModified: ['src/consumer.js'] });
|
|
||||||
|
|
||||||
writeCompanionPlan(tmpDir, '01-02-PLAN.md', 3, ['src/later-artifact.js']);
|
|
||||||
|
|
||||||
const result = runGsdTools(
|
|
||||||
'verify key-links .planning/phases/01-test/01-01-PLAN.md',
|
|
||||||
tmpDir,
|
|
||||||
);
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
||||||
|
|
||||||
const out = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(
|
|
||||||
out.links[0].pending,
|
|
||||||
true,
|
|
||||||
`Expected pending:true for from: file promised by a later-wave plan. Got: ${JSON.stringify(out.links[0])}`,
|
|
||||||
);
|
|
||||||
assert.strictEqual(
|
|
||||||
out.all_verified,
|
|
||||||
true,
|
|
||||||
`Expected all_verified:true (pending links not counted against gate). Got: ${JSON.stringify(out)}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 5. Regression #1202: missing from: file NOT promised by any plan → hard failure ──
|
|
||||||
//
|
|
||||||
// Absence of from: file with no plan promising it must remain a genuine verified:false failure.
|
|
||||||
test('verified:false and all_verified:false when from: file is absent and not promised by any plan', () => {
|
|
||||||
writePlanWithKeyLinks(tmpDir, [
|
|
||||||
'- from: "src/truly-missing.js"',
|
|
||||||
' to: "src/consumer.js"',
|
|
||||||
' via: "no plan promises this"',
|
|
||||||
], { wave: 1, filesModified: ['src/consumer.js'] });
|
|
||||||
|
|
||||||
// No companion plan that promises src/truly-missing.js
|
|
||||||
|
|
||||||
const result = runGsdTools(
|
|
||||||
'verify key-links .planning/phases/01-test/01-01-PLAN.md',
|
|
||||||
tmpDir,
|
|
||||||
);
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
||||||
|
|
||||||
const out = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(
|
|
||||||
out.links[0].verified,
|
|
||||||
false,
|
|
||||||
`Expected verified:false for absent+unpromised from: file. Got: ${JSON.stringify(out.links[0])}`,
|
|
||||||
);
|
|
||||||
assert.strictEqual(
|
|
||||||
out.all_verified,
|
|
||||||
false,
|
|
||||||
`Expected all_verified:false (hard failure). Got: ${JSON.stringify(out)}`,
|
|
||||||
);
|
|
||||||
// pending must not be true
|
|
||||||
assert.notStrictEqual(
|
|
||||||
out.links[0].pending,
|
|
||||||
true,
|
|
||||||
`Expected pending not to be true for an absent+unpromised file. Got: ${JSON.stringify(out.links[0])}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 6. Doc-contract guard: reference example must use a file path for to: ──
|
|
||||||
//
|
|
||||||
// The old reference example had to: "/api/feed" (an HTTP endpoint).
|
|
||||||
// After fix #967, to: must be a relative file path like "app/api/feed/route.ts".
|
|
||||||
// This test reads the canonical docs file and asserts the example is consistent
|
|
||||||
// with the strict-path contract.
|
|
||||||
//
|
|
||||||
// allow-test-rule: <runtime-contract-is-the-product> the plan-md.md reference
|
|
||||||
// example IS the documented authoring surface for key_links; asserting it uses
|
|
||||||
// a file path (not an endpoint) directly tests the documented contract.
|
|
||||||
test('docs/reference/plan-md.md key_links example uses a relative file path for to:, not an HTTP endpoint', () => {
|
|
||||||
// Locate plan-md.md relative to this test file's repo root
|
|
||||||
const docPath = path.join(__dirname, '..', 'docs', 'reference', 'plan-md.md');
|
|
||||||
assert.ok(fs.existsSync(docPath), `plan-md.md not found at ${docPath}`);
|
|
||||||
const content = fs.readFileSync(docPath, 'utf-8'); // allow-test-rule: <runtime-contract-is-the-product> the plan-md.md reference example IS the documented authoring surface for key_links; asserting it uses a file path (not an endpoint) directly tests the documented contract.
|
|
||||||
|
|
||||||
// Find the key_links block in the annotated example (the first YAML frontmatter fence)
|
|
||||||
// The bad old value was: to: "/api/feed"
|
|
||||||
assert.ok(
|
|
||||||
!content.includes('to: "/api/feed"'),
|
|
||||||
'docs/reference/plan-md.md still contains the endpoint-style to: "/api/feed" — ' +
|
|
||||||
'the reference example must use a relative file path (e.g. "app/api/feed/route.ts") ' +
|
|
||||||
'to match the strict file-path contract.',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Also assert the corrected example actually uses a path-like value
|
|
||||||
// (must contain at least one '/' and not start with 'http')
|
|
||||||
const toMatch = content.match(/key_links:[\s\S]*?to:\s*"([^"]+)"/);
|
|
||||||
assert.ok(
|
|
||||||
toMatch,
|
|
||||||
'Could not find a to: field in the key_links example in plan-md.md',
|
|
||||||
);
|
|
||||||
const toValue = toMatch[1];
|
|
||||||
assert.ok(
|
|
||||||
!toValue.startsWith('/api') && !toValue.startsWith('http'),
|
|
||||||
`to: value in the docs example looks like an HTTP endpoint: "${toValue}". ` +
|
|
||||||
'It must be a relative file path.',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Regression test for bug #978: `gsd-tools milestone complete --force` was a
|
|
||||||
* dead flag. The milestone source (src/milestone.cts) has a guard that checks
|
|
||||||
* `options.force` and tells users to "Re-run with --force to override", but the
|
|
||||||
* CLI dispatcher (gsd-core/bin/gsd-tools.cjs) never parsed `--force` and never
|
|
||||||
* passed it into the options object. So `options.force` was always `undefined`
|
|
||||||
* and the guard could never be overridden regardless of what the user typed.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a fixture where the guard will fire:
|
|
||||||
* - STATE.md has `milestone: <version>` so the guard's version-match check is
|
|
||||||
* satisfied.
|
|
||||||
* - ROADMAP.md lists a `### Phase 2: Real Work` heading for that milestone, but
|
|
||||||
* there is NO on-disk phase directory for it.
|
|
||||||
*
|
|
||||||
* This guarantees "unstarted phase" detection without touching any real phases.
|
|
||||||
* NOTE: the unstarted phase must be a REAL phase number — Phase 0 and Phase 999
|
|
||||||
* are backlog/pre-milestone sentinels that are intentionally excluded from this
|
|
||||||
* guard (#1580), so they would not fire it.
|
|
||||||
*/
|
|
||||||
function makeGuardFixture(tmpDir, version) {
|
|
||||||
// STATE.md with frontmatter milestone field matching the version
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
|
||||||
`---\nmilestone: ${version}\n---\n# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// ROADMAP.md — the heading must include the version so getMilestonePhaseFilter
|
|
||||||
// does not return missingExplicitVersion. Phase 2 has no on-disk dir.
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
||||||
`# Roadmap ${version}\n\n### Phase 2: Real Work\n**Goal:** Not started\n`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('bug-978: milestone complete --force overrides unstarted-phase guard', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = createTempProject('gsd-bug-978-');
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('without --force the guard fires and emits the documented error message', () => {
|
|
||||||
makeGuardFixture(tmpDir, 'v1.0');
|
|
||||||
|
|
||||||
const result = runGsdTools(
|
|
||||||
['milestone', 'complete', 'v1.0', '--name', 'Regression Test'],
|
|
||||||
tmpDir,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.strictEqual(result.success, false, 'command should fail without --force');
|
|
||||||
assert.ok(
|
|
||||||
result.error.includes('Re-run with --force to override'),
|
|
||||||
`expected guard error message; got: ${result.error}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('with --force the guard is bypassed and the command succeeds', () => {
|
|
||||||
makeGuardFixture(tmpDir, 'v1.0');
|
|
||||||
|
|
||||||
const result = runGsdTools(
|
|
||||||
['milestone', 'complete', 'v1.0', '--name', 'Regression Test', '--force'],
|
|
||||||
tmpDir,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.ok(
|
|
||||||
result.success,
|
|
||||||
`command should succeed with --force but failed: ${result.error}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const output = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(output.version, 'v1.0');
|
|
||||||
// Milestone entry should have been created even though phase 2 has no dir
|
|
||||||
assert.ok(
|
|
||||||
fs.existsSync(path.join(tmpDir, '.planning', 'MILESTONES.md')),
|
|
||||||
'MILESTONES.md should have been created',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -241,3 +241,391 @@ describe('cjs-command-router-adapter routeCjsCommandFamily', () => {
|
|||||||
assert.equal(errorMessage, 'boom');
|
assert.equal(errorMessage, 'boom');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
// Folded from tests/bug-224-pick-stdout-capture.test.cjs — consolidation epic #1969 (B2 #1971)
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const { describe: __foldDescribe } = require('node:test');
|
||||||
|
__foldDescribe("folded:bug-224-pick-stdout-capture (consolidation epic #1969 B2 #1971)", () => {
|
||||||
|
// allow-test-rule: structural-implementation-guard (see #224)
|
||||||
|
// Bug #224 is a platform-specific (Node 24 + Windows) flake in `--pick` where
|
||||||
|
// stdout interception can produce non-deterministic failures. We lock the seam
|
||||||
|
// contract structurally until we have a deterministic Windows reproduction
|
||||||
|
// harness in CI.
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { describe, test, before } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { runGsdTools } = require('./helpers.cjs');
|
||||||
|
|
||||||
|
const GSD_TOOLS_SRC = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||||
|
|
||||||
|
describe('bug #224: --pick stdout capture contract', () => {
|
||||||
|
let src;
|
||||||
|
|
||||||
|
before(() => {
|
||||||
|
src = fs.readFileSync(GSD_TOOLS_SRC, 'utf-8');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('--pick output still succeeds for current-timestamp command', () => {
|
||||||
|
const result = runGsdTools(['current-timestamp', '--pick', 'timestamp']);
|
||||||
|
assert.strictEqual(result.success, true, result.error || 'expected command to succeed');
|
||||||
|
assert.match(result.output, /^\d{4}-\d{2}-\d{2}T/, 'expected ISO timestamp output');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stdout interception for fd=1 returns a byte count (never undefined)', () => {
|
||||||
|
const mainStart = src.indexOf('async function main()');
|
||||||
|
assert.ok(mainStart !== -1, 'main() must exist');
|
||||||
|
const mainSrc = src.slice(mainStart);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
mainSrc.includes('Buffer.byteLength(') || mainSrc.includes('return data.length'),
|
||||||
|
'stdout interception must return written-byte counts for fd=1 captures'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
// Folded from tests/bug-3243-dotted-command-form.test.cjs — consolidation epic #1969 (B2 #1971)
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const { describe: __foldDescribe } = require('node:test');
|
||||||
|
__foldDescribe("folded:bug-3243-dotted-command-form (consolidation epic #1969 B2 #1971)", () => {
|
||||||
|
/**
|
||||||
|
* Regression tests for bug #3243.
|
||||||
|
*
|
||||||
|
* The CJS dispatcher (gsd-tools.cjs) must accept dotted canonical command
|
||||||
|
* form (e.g. `state.update`) as well as the spaced form (`state update`).
|
||||||
|
* Workflow markdown files emit `gsd-sdk query <domain>.<subcommand>` calls,
|
||||||
|
* and any caller that bypasses the SDK (stale npm binary, direct shell-out,
|
||||||
|
* third-party script) would hit "Unknown command: <domain>.<subcommand>".
|
||||||
|
*
|
||||||
|
* The fix: a top-of-main() shim that splits args[0] on the first `.` when
|
||||||
|
* present and normalizes to the spaced form before the switch is reached.
|
||||||
|
*
|
||||||
|
* This test file uses runGsdTools() — never readFileSync + .includes().
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||||
|
|
||||||
|
describe('bug #3243: CJS dispatcher accepts dotted canonical command form', () => {
|
||||||
|
let tmpDir;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = createTempProject();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup(tmpDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── generate-slug: no project structure needed, deterministic output ────
|
||||||
|
|
||||||
|
test('generate-slug.hello-world (dotted) produces the same slug as spaced form', () => {
|
||||||
|
const spaced = runGsdTools(['generate-slug', 'hello-world'], tmpDir);
|
||||||
|
assert.strictEqual(spaced.success, true, [
|
||||||
|
'control (spaced form) failed:',
|
||||||
|
spaced.error,
|
||||||
|
].join(' '));
|
||||||
|
|
||||||
|
const dotted = runGsdTools(['generate-slug.hello-world'], tmpDir);
|
||||||
|
// Before the fix this errors: "Unknown command: generate-slug.hello-world"
|
||||||
|
assert.strictEqual(dotted.success, true, [
|
||||||
|
'dotted form must not emit "Unknown command":',
|
||||||
|
dotted.error,
|
||||||
|
].join(' '));
|
||||||
|
assert.strictEqual(dotted.output, spaced.output,
|
||||||
|
'dotted form must produce identical output to spaced form');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('current-timestamp.date (dotted) produces the same output as spaced form', () => {
|
||||||
|
const spaced = runGsdTools(['current-timestamp', 'date'], tmpDir);
|
||||||
|
assert.strictEqual(spaced.success, true, [
|
||||||
|
'control (spaced form) failed:',
|
||||||
|
spaced.error,
|
||||||
|
].join(' '));
|
||||||
|
|
||||||
|
const dotted = runGsdTools(['current-timestamp.date'], tmpDir);
|
||||||
|
assert.strictEqual(dotted.success, true, [
|
||||||
|
'dotted form must not emit "Unknown command":',
|
||||||
|
dotted.error,
|
||||||
|
].join(' '));
|
||||||
|
assert.strictEqual(dotted.output, spaced.output,
|
||||||
|
'dotted form must produce identical output to spaced form');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Commands with subcommands that need a project ────────────────────────
|
||||||
|
|
||||||
|
test('validate.plan (dotted) routes into validate handler, not "Unknown command"', () => {
|
||||||
|
const dotted = runGsdTools(['validate.plan'], tmpDir);
|
||||||
|
// Before the fix: success=false, error contains "Unknown command: validate.plan"
|
||||||
|
// After the fix: success=false is still possible (validate needs a PLAN.md),
|
||||||
|
// but the error must NOT mention "Unknown command".
|
||||||
|
const errText = dotted.error || '';
|
||||||
|
assert.ok(
|
||||||
|
!errText.includes('Unknown command: validate.plan'),
|
||||||
|
[
|
||||||
|
'dotted form must not produce "Unknown command: validate.plan".',
|
||||||
|
'Got error:', errText,
|
||||||
|
].join('\n')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('roadmap.analyze (dotted) routes into roadmap handler, not "Unknown command"', () => {
|
||||||
|
const dotted = runGsdTools(['roadmap.analyze'], tmpDir);
|
||||||
|
// success=true means it reached the handler (even if handler reports no ROADMAP.md).
|
||||||
|
// success=false means dispatcher rejected it — assert the error is NOT "Unknown command".
|
||||||
|
const errText = dotted.error || '';
|
||||||
|
assert.ok(
|
||||||
|
!errText.includes('Unknown command: roadmap.analyze'),
|
||||||
|
[
|
||||||
|
'dotted form must not produce "Unknown command: roadmap.analyze".',
|
||||||
|
'Got error:', errText,
|
||||||
|
].join('\n')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('phases.list (dotted) routes into phases handler, not "Unknown command"', () => {
|
||||||
|
const dotted = runGsdTools(['phases.list'], tmpDir);
|
||||||
|
const errText = dotted.error || '';
|
||||||
|
assert.ok(
|
||||||
|
!errText.includes('Unknown command: phases.list'),
|
||||||
|
[
|
||||||
|
'dotted form must not produce "Unknown command: phases.list".',
|
||||||
|
'Got error:', errText,
|
||||||
|
].join('\n')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Multi-dot commands: split on first dot only ──────────────────────────
|
||||||
|
|
||||||
|
test('check.decision-coverage-plan (multi-dot-safe: first dot splits)', () => {
|
||||||
|
const dotted = runGsdTools(['check.decision-coverage-plan'], tmpDir);
|
||||||
|
// "check" is not a known top-level command currently, so this will still
|
||||||
|
// fail — but the error must NOT say "Unknown command: check.decision-coverage-plan"
|
||||||
|
// (the dotted form); it should say something about "check" (the split result).
|
||||||
|
const errText = dotted.error || '';
|
||||||
|
assert.ok(
|
||||||
|
!errText.includes('Unknown command: check.decision-coverage-plan'),
|
||||||
|
[
|
||||||
|
'multi-dot dotted form must not be passed verbatim to "Unknown command".',
|
||||||
|
'Got error:', errText,
|
||||||
|
].join('\n')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Edge cases ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('command without dots is unchanged (existing behaviour preserved)', () => {
|
||||||
|
const result = runGsdTools(['generate-slug', 'no-dots-here'], tmpDir);
|
||||||
|
assert.strictEqual(result.success, true, [
|
||||||
|
'spaced-only invocation must still work:',
|
||||||
|
result.error,
|
||||||
|
].join(' '));
|
||||||
|
assert.ok(result.output.length > 0, 'output must be non-empty');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leading-dot arg (e.g. .hidden) is not mis-routed by the shim', () => {
|
||||||
|
// A leading dot in args[0] like ".hidden" has head="" (empty) after split,
|
||||||
|
// so the shim must reject it and fall through to the existing "Unknown command"
|
||||||
|
// path (not silently reroute to an empty-string command).
|
||||||
|
const result = runGsdTools(['.hidden'], tmpDir);
|
||||||
|
assert.strictEqual(result.success, false, 'leading-dot arg must not succeed');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── "Unknown command" error message improvement ──────────────────────────
|
||||||
|
|
||||||
|
test('"Unknown command" error for dotted form suggests spaced equivalent', () => {
|
||||||
|
// A genuinely unknown dotted command (e.g. "foo.bar") should include a
|
||||||
|
// "did you mean" hint pointing at the spaced form "foo bar".
|
||||||
|
const result = runGsdTools(['foo.bar'], tmpDir);
|
||||||
|
assert.strictEqual(result.success, false, '"foo.bar" must fail');
|
||||||
|
assert.ok(
|
||||||
|
result.error.includes('foo bar'),
|
||||||
|
[
|
||||||
|
'error for unknown dotted command should suggest spaced form "foo bar".',
|
||||||
|
'Got:', result.error,
|
||||||
|
].join('\n')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multi-dot unknown command suggestion uses first-dot split only (a.b.c → "a b.c")', () => {
|
||||||
|
// The shim splits only on the FIRST dot, so the suggestion must mirror that:
|
||||||
|
// "a.b.c" → head="a", rest="b.c" → suggest "a b.c", NOT "a b c".
|
||||||
|
const result = runGsdTools(['a.b.c'], tmpDir);
|
||||||
|
assert.strictEqual(result.success, false, '"a.b.c" must fail');
|
||||||
|
assert.ok(
|
||||||
|
result.error.includes('a b.c'),
|
||||||
|
[
|
||||||
|
'suggestion for multi-dot unknown command must use first-dot split: "a b.c".',
|
||||||
|
'Got:', result.error,
|
||||||
|
].join('\n')
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
!result.error.includes('a b c'),
|
||||||
|
[
|
||||||
|
'suggestion must NOT replace all dots ("a b c" is wrong — only first dot splits).',
|
||||||
|
'Got:', result.error,
|
||||||
|
].join('\n')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
// Folded from tests/bug-3631-router-raw-flag.test.cjs — consolidation epic #1969 (B2 #1971)
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const { describe: __foldDescribe } = require('node:test');
|
||||||
|
__foldDescribe("folded:bug-3631-router-raw-flag (consolidation epic #1969 B2 #1971)", () => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression tests for #3631 — SDK dispatch path in family routers must
|
||||||
|
* forward the `--raw` flag through to `output()`.
|
||||||
|
*
|
||||||
|
* Before the fix, every `*-command-router.cjs` `sdkHandler` called
|
||||||
|
* `output(result.data)` without the second positional `raw` argument or the
|
||||||
|
* third positional `rawValue`. With `--raw` set, the SDK path therefore
|
||||||
|
* emitted JSON-stringified data ({"next":"2.1",...}) instead of the scalar
|
||||||
|
* the CJS path used to print (e.g. `2.1`).
|
||||||
|
*
|
||||||
|
* Both tests below exercise the live SDK path:
|
||||||
|
* 1. `phase next-decimal --raw <base>` must emit the next-decimal token.
|
||||||
|
* 2. `roadmap get-phase --raw <id>` must emit the phase's roadmap section.
|
||||||
|
*
|
||||||
|
* Per CONTRIBUTING.md: assertions are on structured (scalar) tokens, not
|
||||||
|
* substring grep against full JSON.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { test, describe } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const os = require('node:os');
|
||||||
|
const { execFileSync } = require('node:child_process');
|
||||||
|
const { cleanup } = require('./helpers.cjs');
|
||||||
|
|
||||||
|
const GSD_TOOLS = path.resolve(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||||
|
|
||||||
|
function run(args, cwd) {
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
stdout: execFileSync(process.execPath, [GSD_TOOLS, ...args], {
|
||||||
|
cwd,
|
||||||
|
encoding: 'utf-8',
|
||||||
|
timeout: 15000,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
stdout: (e.stdout && e.stdout.toString()) || '',
|
||||||
|
stderr: (e.stderr && e.stderr.toString()) || '',
|
||||||
|
code: e.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFixture() {
|
||||||
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3631-'));
|
||||||
|
const planning = path.join(tmp, '.planning');
|
||||||
|
fs.mkdirSync(path.join(planning, 'phases'), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(planning, 'ROADMAP.md'),
|
||||||
|
[
|
||||||
|
'# Project Roadmap',
|
||||||
|
'',
|
||||||
|
'## v1',
|
||||||
|
'',
|
||||||
|
'### Phase 1: First',
|
||||||
|
'',
|
||||||
|
'Body of phase 1.',
|
||||||
|
'',
|
||||||
|
'### Phase 2: Second',
|
||||||
|
'',
|
||||||
|
'Body of phase 2.',
|
||||||
|
'',
|
||||||
|
].join('\n')
|
||||||
|
);
|
||||||
|
// PROJECT.md anchors the planning root for callers that resolve it.
|
||||||
|
fs.writeFileSync(path.join(planning, 'PROJECT.md'), '# Test\n');
|
||||||
|
return tmp;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('bug #3631 — SDK family routers forward --raw to output()', () => {
|
||||||
|
test('phase next-decimal --raw emits the scalar next-decimal token (not JSON)', () => {
|
||||||
|
const tmp = makeFixture();
|
||||||
|
try {
|
||||||
|
const res = run(['phase', 'next-decimal', '--raw', '1'], tmp);
|
||||||
|
assert.ok(
|
||||||
|
res.ok,
|
||||||
|
`command must succeed; got code=${res.code} stderr=${res.stderr}`
|
||||||
|
);
|
||||||
|
const trimmed = res.stdout.trim();
|
||||||
|
// Scalar form — must be a phase id token like "1.1", not a JSON object.
|
||||||
|
assert.doesNotMatch(
|
||||||
|
trimmed,
|
||||||
|
/^\{/,
|
||||||
|
`--raw must not emit JSON; got: ${trimmed}`
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
trimmed,
|
||||||
|
/^0*\d+(?:\.\d+)?$/,
|
||||||
|
`--raw must emit a scalar phase id; got: ${trimmed}`
|
||||||
|
);
|
||||||
|
// SDK and CJS both normalize the base phase before computing the next-
|
||||||
|
// decimal token; CJS emits "1.1" while SDK normalizes "1"→"01" and emits
|
||||||
|
// "01.1". Both are valid scalar projections — assert on parity with the
|
||||||
|
// computed-next semantics rather than the exact padding form.
|
||||||
|
assert.ok(
|
||||||
|
trimmed === '1.1' || trimmed === '01.1',
|
||||||
|
`expected next-decimal of base "1" to be 1.1 or 01.1; got: ${trimmed}`
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
cleanup(tmp);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('roadmap get-phase --raw emits the phase section (not JSON)', () => {
|
||||||
|
const tmp = makeFixture();
|
||||||
|
try {
|
||||||
|
const res = run(['roadmap', 'get-phase', '--raw', '2'], tmp);
|
||||||
|
assert.ok(
|
||||||
|
res.ok,
|
||||||
|
`command must succeed; got code=${res.code} stderr=${res.stderr}`
|
||||||
|
);
|
||||||
|
const trimmed = res.stdout.trim();
|
||||||
|
assert.doesNotMatch(
|
||||||
|
trimmed,
|
||||||
|
/^\{/,
|
||||||
|
`--raw must not emit JSON; got: ${trimmed.slice(0, 80)}`
|
||||||
|
);
|
||||||
|
// Section text starts with the heading.
|
||||||
|
assert.match(
|
||||||
|
trimmed,
|
||||||
|
/Phase 2:\s*Second/,
|
||||||
|
`--raw must emit the section body containing the Phase 2 heading; got: ${trimmed.slice(0, 80)}`
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
cleanup(tmp);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -352,3 +352,139 @@ describe('generate-claude-md skills section', () => {
|
|||||||
assert.ok(skillsIdx < workflowIdx, 'Skills section should come before Workflow Enforcement');
|
assert.ok(skillsIdx < workflowIdx, 'Skills section should come before Workflow Enforcement');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
// Folded from tests/bug-3163-codex-agents-md.test.cjs — consolidation epic #1969 (B2 #1971)
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const { describe: __foldDescribe } = require('node:test');
|
||||||
|
__foldDescribe("folded:bug-3163-codex-agents-md (consolidation epic #1969 B2 #1971)", () => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bug #3163: generate-claude-md should write to AGENTS.md on Codex runtime.
|
||||||
|
*
|
||||||
|
* When config.runtime === 'codex' (or GSD_RUNTIME=codex), the generate-claude-md
|
||||||
|
* handler must resolve the output path to AGENTS.md, not CLAUDE.md.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||||
|
|
||||||
|
describe('bug #3163: generate-claude-md uses AGENTS.md for Codex runtime', () => {
|
||||||
|
let tmpDir;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = createTempProject();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(tmpDir, '.planning', 'PROJECT.md'),
|
||||||
|
'# Test Project\n\nA Codex-hosted project.\n',
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup(tmpDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('writes to AGENTS.md when config.runtime is codex and no --output given', () => {
|
||||||
|
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
||||||
|
fs.writeFileSync(
|
||||||
|
configPath,
|
||||||
|
JSON.stringify({ runtime: 'codex', claude_md_path: './CLAUDE.md' }),
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = runGsdTools('generate-claude-md', tmpDir, { HOME: tmpDir });
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result.output);
|
||||||
|
const realTmpDir = fs.realpathSync(tmpDir);
|
||||||
|
const expectedAgentsPath = path.join(realTmpDir, 'AGENTS.md');
|
||||||
|
|
||||||
|
// The returned path must be AGENTS.md, not CLAUDE.md
|
||||||
|
assert.strictEqual(parsed.claude_md_path, expectedAgentsPath,
|
||||||
|
`Expected output path to be AGENTS.md but got: ${parsed.claude_md_path}`
|
||||||
|
);
|
||||||
|
// AGENTS.md must exist on disk
|
||||||
|
assert.ok(fs.existsSync(expectedAgentsPath), 'AGENTS.md must exist after generation');
|
||||||
|
// CLAUDE.md must NOT be created
|
||||||
|
assert.ok(!fs.existsSync(path.join(realTmpDir, 'CLAUDE.md')), 'CLAUDE.md must not be created for Codex runtime');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('writes to AGENTS.md when GSD_RUNTIME=codex env var is set (env takes precedence over config)', () => {
|
||||||
|
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
||||||
|
// Config says runtime: claude but env overrides to codex
|
||||||
|
fs.writeFileSync(
|
||||||
|
configPath,
|
||||||
|
JSON.stringify({ runtime: 'claude', claude_md_path: './CLAUDE.md' }),
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = runGsdTools('generate-claude-md', tmpDir, { HOME: tmpDir, GSD_RUNTIME: 'codex' });
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result.output);
|
||||||
|
const realTmpDir = fs.realpathSync(tmpDir);
|
||||||
|
const expectedAgentsPath = path.join(realTmpDir, 'AGENTS.md');
|
||||||
|
|
||||||
|
assert.strictEqual(parsed.claude_md_path, expectedAgentsPath,
|
||||||
|
`Expected output path to be AGENTS.md but got: ${parsed.claude_md_path}`
|
||||||
|
);
|
||||||
|
assert.ok(fs.existsSync(expectedAgentsPath), 'AGENTS.md must exist after generation');
|
||||||
|
assert.ok(!fs.existsSync(path.join(realTmpDir, 'CLAUDE.md')), 'CLAUDE.md must not be created when GSD_RUNTIME=codex');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('--output flag overrides runtime detection when explicitly provided', () => {
|
||||||
|
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
||||||
|
fs.writeFileSync(
|
||||||
|
configPath,
|
||||||
|
JSON.stringify({ runtime: 'codex', claude_md_path: './CLAUDE.md' }),
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
|
||||||
|
// When --output is explicitly provided, it must be honoured regardless of runtime
|
||||||
|
const result = runGsdTools(
|
||||||
|
['generate-claude-md', '--output', 'EXPLICIT-OUTPUT.md'],
|
||||||
|
tmpDir,
|
||||||
|
{ HOME: tmpDir }
|
||||||
|
);
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result.output);
|
||||||
|
const realTmpDir = fs.realpathSync(tmpDir);
|
||||||
|
assert.strictEqual(
|
||||||
|
parsed.claude_md_path,
|
||||||
|
path.join(realTmpDir, 'EXPLICIT-OUTPUT.md'),
|
||||||
|
`Expected explicit --output to be honoured, got: ${parsed.claude_md_path}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-codex runtime still writes to CLAUDE.md', () => {
|
||||||
|
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
||||||
|
fs.writeFileSync(
|
||||||
|
configPath,
|
||||||
|
JSON.stringify({ runtime: 'claude', claude_md_path: './CLAUDE.md' }),
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = runGsdTools('generate-claude-md', tmpDir, { HOME: tmpDir });
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result.output);
|
||||||
|
const realTmpDir = fs.realpathSync(tmpDir);
|
||||||
|
assert.strictEqual(
|
||||||
|
parsed.claude_md_path,
|
||||||
|
path.join(realTmpDir, 'CLAUDE.md'),
|
||||||
|
`Expected CLAUDE.md for claude runtime, got: ${parsed.claude_md_path}`
|
||||||
|
);
|
||||||
|
assert.ok(fs.existsSync(path.join(realTmpDir, 'CLAUDE.md')), 'CLAUDE.md must exist for claude runtime');
|
||||||
|
assert.ok(!fs.existsSync(path.join(realTmpDir, 'AGENTS.md')), 'AGENTS.md must not be created for claude runtime');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -949,3 +949,855 @@ describe('CommandRoutingHub — exitReason? field on InvalidArgs (#1644 / amendm
|
|||||||
`Extended InvalidArgs must not be coerced to HandlerFailure; got kind: ${result.kind}`);
|
`Extended InvalidArgs must not be coerced to HandlerFailure; got kind: ${result.kind}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
// Folded from tests/bug-167-query-meta-command.test.cjs — consolidation epic #1969 (B2 #1971)
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const { describe: __foldDescribe } = require('node:test');
|
||||||
|
__foldDescribe("folded:bug-167-query-meta-command (consolidation epic #1969 B2 #1971)", () => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { runGsdTools } = require('./helpers.cjs');
|
||||||
|
|
||||||
|
test('bug #167: query meta-command prefixes direct gsd-tools calls', () => {
|
||||||
|
const direct = runGsdTools(['init.progress']);
|
||||||
|
assert.equal(direct.success, true, `init.progress failed: ${direct.error || direct.output}`);
|
||||||
|
|
||||||
|
const meta = runGsdTools(['query', 'init.progress']);
|
||||||
|
assert.equal(meta.success, true, `query init.progress failed: ${meta.error || meta.output}`);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
JSON.parse(meta.output),
|
||||||
|
JSON.parse(direct.output),
|
||||||
|
'query-prefixed and direct invocations should return identical init.progress payloads'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
// Folded from tests/bug-1818-unknown-flags.test.cjs — consolidation epic #1969 (B2 #1971)
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const { describe: __foldDescribe } = require('node:test');
|
||||||
|
__foldDescribe("folded:bug-1818-unknown-flags (consolidation epic #1969 B2 #1971)", () => {
|
||||||
|
/**
|
||||||
|
* Regression test for bug #1818, updated for #3019.
|
||||||
|
*
|
||||||
|
* Original #1818 invariant: gsd-tools must NOT silently ignore --help/-h
|
||||||
|
* and proceed with a destructive command — that turned AI-agent
|
||||||
|
* hallucinations into accidental data loss (e.g. `phases clear --help`
|
||||||
|
* deleting phase dirs because the flag was dropped).
|
||||||
|
*
|
||||||
|
* #3019 update: the same destructive-protection invariant still holds,
|
||||||
|
* but the response shape changed. Previously --help → non-zero error
|
||||||
|
* exit. Now --help → render top-level usage and exit 0 WITHOUT running
|
||||||
|
* the command. Both shapes satisfy the original invariant ("the
|
||||||
|
* destructive command did not execute"); the new shape also restores
|
||||||
|
* subcommand discoverability for `gsd-sdk query <subcommand> --help`.
|
||||||
|
*
|
||||||
|
* The tests therefore assert two things:
|
||||||
|
* 1. The destructive command did NOT run (anti-hallucination invariant).
|
||||||
|
* 2. The output contains the top-level usage (#3019 discoverability).
|
||||||
|
*
|
||||||
|
* --version remains rejected — it's never a valid gsd-tools flag and has
|
||||||
|
* no discovery use-case.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { runGsdTools, createTempProject, cleanup, isUsageOutput } = require('./helpers.cjs');
|
||||||
|
|
||||||
|
describe('unknown flag guard (bug #1818, updated for #3019)', () => {
|
||||||
|
let tmpDir;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = createTempProject();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup(tmpDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── --help renders usage and does NOT run the destructive command ────────
|
||||||
|
|
||||||
|
test('phases clear --help renders usage and does NOT clear phase dirs', () => {
|
||||||
|
// Create a sentinel phase dir so we can assert it survives.
|
||||||
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', 'phase-99');
|
||||||
|
fs.mkdirSync(phaseDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(phaseDir, 'PLAN.md'), 'sentinel');
|
||||||
|
|
||||||
|
const result = runGsdTools(['phases', 'clear', '--help'], tmpDir);
|
||||||
|
assert.strictEqual(result.success, true, 'help renders, no error exit');
|
||||||
|
assert.ok(isUsageOutput(result.output), `expected top-level usage, got: ${result.output}`);
|
||||||
|
// Anti-hallucination invariant: the destructive command did NOT run.
|
||||||
|
assert.ok(fs.existsSync(phaseDir), 'phase dir must survive — clear must not have executed');
|
||||||
|
assert.ok(fs.existsSync(path.join(phaseDir, 'PLAN.md')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generate-slug hello --help renders usage and does NOT emit a slug', () => {
|
||||||
|
const ok = runGsdTools(['generate-slug', 'hello'], tmpDir);
|
||||||
|
assert.strictEqual(ok.success, true, 'control: generate-slug works without --help');
|
||||||
|
// The control output is just the slug; the help output is the usage.
|
||||||
|
const slugOut = ok.output;
|
||||||
|
assert.ok(slugOut && !isUsageOutput(slugOut), `control should not be usage: ${slugOut}`);
|
||||||
|
|
||||||
|
const result = runGsdTools(['generate-slug', 'hello', '--help'], tmpDir);
|
||||||
|
assert.strictEqual(result.success, true);
|
||||||
|
assert.ok(isUsageOutput(result.output), 'help renders top-level usage');
|
||||||
|
assert.notEqual(result.output, slugOut, 'help output must differ from the slug — generate-slug must not have run');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('phase complete --help renders usage and does NOT mark a phase complete', () => {
|
||||||
|
const result = runGsdTools(['phase', 'complete', '--help'], tmpDir);
|
||||||
|
assert.strictEqual(result.success, true);
|
||||||
|
assert.ok(isUsageOutput(result.output));
|
||||||
|
// success:true + isUsageOutput is sufficient: if the destructive path
|
||||||
|
// had executed it would have emitted a phase-resolution error to stderr
|
||||||
|
// (success:false), not the usage to stdout (success:true).
|
||||||
|
});
|
||||||
|
|
||||||
|
test('state load --help renders usage', () => {
|
||||||
|
const result = runGsdTools(['state', 'load', '--help'], tmpDir);
|
||||||
|
assert.strictEqual(result.success, true);
|
||||||
|
assert.ok(isUsageOutput(result.output));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── -h shorthand: same shape ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('phases clear -h renders usage and does NOT clear phase dirs', () => {
|
||||||
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', 'phase-42');
|
||||||
|
fs.mkdirSync(phaseDir, { recursive: true });
|
||||||
|
const result = runGsdTools(['phases', 'clear', '-h'], tmpDir);
|
||||||
|
assert.strictEqual(result.success, true);
|
||||||
|
assert.ok(isUsageOutput(result.output));
|
||||||
|
assert.ok(fs.existsSync(phaseDir), 'phase dir must survive');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generate-slug hello -h renders usage', () => {
|
||||||
|
const result = runGsdTools(['generate-slug', 'hello', '-h'], tmpDir);
|
||||||
|
assert.strictEqual(result.success, true);
|
||||||
|
assert.ok(isUsageOutput(result.output));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── --version is still rejected — no discovery use-case ──────────────────
|
||||||
|
|
||||||
|
test('generate-slug hello --version is rejected', () => {
|
||||||
|
const result = runGsdTools(['generate-slug', 'hello', '--version'], tmpDir);
|
||||||
|
assert.strictEqual(result.success, false);
|
||||||
|
assert.match(result.error, /--version/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── current-timestamp --help: same as the others ─────────────────────────
|
||||||
|
|
||||||
|
test('current-timestamp --help renders usage', () => {
|
||||||
|
const result = runGsdTools(['current-timestamp', '--help'], tmpDir);
|
||||||
|
assert.strictEqual(result.success, true);
|
||||||
|
assert.ok(isUsageOutput(result.output));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
// Folded from tests/feat-3255-json-errors-mode.test.cjs — consolidation epic #1969 (B2 #1971)
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const { describe: __foldDescribe } = require('node:test');
|
||||||
|
__foldDescribe("folded:feat-3255-json-errors-mode (consolidation epic #1969 B2 #1971)", () => {
|
||||||
|
/**
|
||||||
|
* Tests for the --json-errors mode added in #3255.
|
||||||
|
*
|
||||||
|
* When gsd-tools is invoked with --json-errors, all error() calls emit a
|
||||||
|
* structured JSON object to stderr:
|
||||||
|
*
|
||||||
|
* { ok: false, reason: "<error_code>", message: "<human text>" }
|
||||||
|
*
|
||||||
|
* This lets tests assert on typed reason codes instead of grepping free-form
|
||||||
|
* stderr text. All assertions below parse the captured stderr via JSON.parse
|
||||||
|
* and inspect typed fields — never result.error.includes() (#2974 / k001).
|
||||||
|
*
|
||||||
|
* Covered error paths (representative set, each exercises a different branch):
|
||||||
|
* 1. Unknown top-level command → reason: "sdk_unknown_command"
|
||||||
|
* 2. Unknown dotted command → reason: "sdk_unknown_command"
|
||||||
|
* 3. Missing required argument → reason: "usage" (--pick without value)
|
||||||
|
* 4. Config key not found → reason: "config_key_not_found"
|
||||||
|
* 5. Unknown subcommand → reason: "sdk_unknown_command"
|
||||||
|
* 6. GSD_JSON_ERRORS=1 env var → same structured output without --flag
|
||||||
|
* 7. Successful command unaffected
|
||||||
|
* 8. Error object shape is stable ({ok, reason, message})
|
||||||
|
* 9. Single error line per invocation
|
||||||
|
* 10. Unknown flag → reason: "usage"
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||||
|
|
||||||
|
// Helper: run gsd-tools with --json-errors and parse the structured stderr.
|
||||||
|
// Returns the parsed object, or throws if stderr is not valid JSON.
|
||||||
|
function runJsonErrors(args, tmpDir, env = {}) {
|
||||||
|
const allArgs = ['--json-errors', ...args];
|
||||||
|
const result = runGsdTools(allArgs, tmpDir, env);
|
||||||
|
// Must have failed
|
||||||
|
assert.strictEqual(result.success, false,
|
||||||
|
`Expected failure with --json-errors for args: ${args.join(' ')}\nstdout: ${result.output}\nstderr: ${result.error}`);
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(result.error);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(
|
||||||
|
`--json-errors must emit valid JSON on stderr.\n` +
|
||||||
|
`Args: ${args.join(' ')}\n` +
|
||||||
|
`stderr: ${result.error}\n` +
|
||||||
|
`parse error: ${e.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('feat #3255: --json-errors mode emits structured error objects', () => {
|
||||||
|
let tmpDir;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = createTempProject();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup(tmpDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 1. Unknown top-level command ─────────────────────────────────────────
|
||||||
|
|
||||||
|
test('unknown top-level command emits { ok: false, reason: "sdk_unknown_command" }', () => {
|
||||||
|
const parsed = runJsonErrors(['totally-unknown-command-xyzzy'], tmpDir);
|
||||||
|
|
||||||
|
assert.strictEqual(parsed.ok, false,
|
||||||
|
'error object must have ok: false');
|
||||||
|
assert.strictEqual(parsed.reason, 'sdk_unknown_command',
|
||||||
|
`reason must be "sdk_unknown_command", got: ${parsed.reason}`);
|
||||||
|
assert.ok(typeof parsed.message === 'string' && parsed.message.length > 0,
|
||||||
|
'message must be a non-empty string');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 2. Unknown dotted command ────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('unknown dotted command (foo.bar) emits { ok: false, reason: "sdk_unknown_command" }', () => {
|
||||||
|
const parsed = runJsonErrors(['foo.bar'], tmpDir);
|
||||||
|
|
||||||
|
assert.strictEqual(parsed.ok, false,
|
||||||
|
'error object must have ok: false');
|
||||||
|
assert.strictEqual(parsed.reason, 'sdk_unknown_command',
|
||||||
|
`dotted unknown command reason must be "sdk_unknown_command", got: ${parsed.reason}`);
|
||||||
|
assert.ok(typeof parsed.message === 'string' && parsed.message.length > 0,
|
||||||
|
'message must be a non-empty string');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 3. Missing --pick value ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('--pick without value emits { ok: false, reason: "usage" }', () => {
|
||||||
|
const parsed = runJsonErrors(['generate-slug', 'test-text', '--pick'], tmpDir);
|
||||||
|
|
||||||
|
assert.strictEqual(parsed.ok, false,
|
||||||
|
'error object must have ok: false');
|
||||||
|
assert.strictEqual(parsed.reason, 'usage',
|
||||||
|
`missing --pick value reason must be "usage", got: ${parsed.reason}`);
|
||||||
|
assert.ok(typeof parsed.message === 'string' && parsed.message.length > 0,
|
||||||
|
'message must be a non-empty string');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 4. Config key not found ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('config-get for absent key emits { ok: false, reason: "config_key_not_found" }', () => {
|
||||||
|
// Initialise config.json first so we reach the "key not found" branch
|
||||||
|
// rather than the "no config.json" branch.
|
||||||
|
runGsdTools(['config-ensure-section'], tmpDir);
|
||||||
|
|
||||||
|
const parsed = runJsonErrors(['config-get', 'nonexistent_config_key_xyzzy'], tmpDir);
|
||||||
|
|
||||||
|
assert.strictEqual(parsed.ok, false,
|
||||||
|
'error object must have ok: false');
|
||||||
|
assert.strictEqual(parsed.reason, 'config_key_not_found',
|
||||||
|
`reason must be "config_key_not_found", got: ${parsed.reason}`);
|
||||||
|
assert.ok(typeof parsed.message === 'string' && parsed.message.length > 0,
|
||||||
|
'message must be a non-empty string');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 5. Unknown subcommand within a domain ────────────────────────────────
|
||||||
|
|
||||||
|
test('unknown intel subcommand emits { ok: false, reason: "sdk_unknown_command" }', () => {
|
||||||
|
const parsed = runJsonErrors(['intel', 'bogus-subcommand-xyzzy'], tmpDir);
|
||||||
|
|
||||||
|
assert.strictEqual(parsed.ok, false,
|
||||||
|
'error object must have ok: false');
|
||||||
|
assert.strictEqual(parsed.reason, 'sdk_unknown_command',
|
||||||
|
`unknown subcommand reason must be "sdk_unknown_command", got: ${parsed.reason}`);
|
||||||
|
assert.ok(typeof parsed.message === 'string' && parsed.message.length > 0,
|
||||||
|
'message must be a non-empty string');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 6. GSD_JSON_ERRORS=1 env var activates structured mode ───────────────
|
||||||
|
|
||||||
|
test('GSD_JSON_ERRORS=1 env var produces same structured error as --json-errors flag', () => {
|
||||||
|
// Run with env var instead of --json-errors flag
|
||||||
|
const result = runGsdTools(
|
||||||
|
['totally-unknown-command-xyzzy'],
|
||||||
|
tmpDir,
|
||||||
|
{ GSD_JSON_ERRORS: '1' }
|
||||||
|
);
|
||||||
|
assert.strictEqual(result.success, false,
|
||||||
|
'command must fail');
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(result.error);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(
|
||||||
|
`GSD_JSON_ERRORS=1 must emit valid JSON on stderr.\n` +
|
||||||
|
`stderr: ${result.error}\n` +
|
||||||
|
`parse error: ${e.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.strictEqual(parsed.ok, false,
|
||||||
|
'error object must have ok: false');
|
||||||
|
assert.strictEqual(parsed.reason, 'sdk_unknown_command',
|
||||||
|
`reason must be "sdk_unknown_command", got: ${parsed.reason}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 7. Successful commands are unaffected by --json-errors ───────────────
|
||||||
|
|
||||||
|
test('successful command with --json-errors flag still succeeds normally', () => {
|
||||||
|
const result = runGsdTools(
|
||||||
|
['--json-errors', 'generate-slug', 'hello-world'],
|
||||||
|
tmpDir
|
||||||
|
);
|
||||||
|
assert.strictEqual(result.success, true,
|
||||||
|
`Successful command must not be broken by --json-errors flag.\nstderr: ${result.error}`);
|
||||||
|
assert.ok(result.output.length > 0,
|
||||||
|
'stdout must be non-empty for successful generate-slug');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 8. Error object shape is stable (no extra top-level keys) ────────────
|
||||||
|
|
||||||
|
test('error object contains exactly {ok, reason, message} — no extra keys', () => {
|
||||||
|
const parsed = runJsonErrors(['totally-unknown-command-xyzzy'], tmpDir);
|
||||||
|
|
||||||
|
const keys = Object.keys(parsed).sort();
|
||||||
|
assert.deepStrictEqual(keys, ['message', 'ok', 'reason'],
|
||||||
|
`error object must have exactly {ok, reason, message}. Got keys: ${keys.join(', ')}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 9. Multiple errors in one session: only the first error is emitted ───
|
||||||
|
|
||||||
|
test('only one error JSON line is emitted per invocation (process exits on first error)', () => {
|
||||||
|
const result = runGsdTools(
|
||||||
|
['--json-errors', 'totally-unknown-command-xyzzy'],
|
||||||
|
tmpDir
|
||||||
|
);
|
||||||
|
assert.strictEqual(result.success, false, 'must fail');
|
||||||
|
const lines = result.error.trim().split('\n').filter(l => l.length > 0);
|
||||||
|
assert.strictEqual(lines.length, 1,
|
||||||
|
`stderr must contain exactly one JSON line, got ${lines.length}:\n${result.error}`);
|
||||||
|
// Also verify the single line is valid JSON
|
||||||
|
const parsed = JSON.parse(lines[0]);
|
||||||
|
assert.strictEqual(parsed.ok, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 10. Unknown flag emits { ok: false, reason: "usage" } ────────────────
|
||||||
|
|
||||||
|
test('unknown version flag emits { ok: false, reason: "usage" }', () => {
|
||||||
|
const parsed = runJsonErrors(['--version', 'generate-slug', 'x'], tmpDir);
|
||||||
|
|
||||||
|
assert.strictEqual(parsed.ok, false, 'error object must have ok: false');
|
||||||
|
assert.strictEqual(parsed.reason, 'usage',
|
||||||
|
`--version flag reason must be "usage", got: ${parsed.reason}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
// Folded from tests/feat-3310-followup-typed-codes.test.cjs — consolidation epic #1969 (B2 #1971)
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const { describe: __foldDescribe } = require('node:test');
|
||||||
|
__foldDescribe("folded:feat-3310-followup-typed-codes (consolidation epic #1969 B2 #1971)", () => {
|
||||||
|
/**
|
||||||
|
* Follow-up tests for #3310: every remaining `error()` call at a subcommand
|
||||||
|
* boundary or usage check in `gsd-tools.cjs` carries a typed `ERROR_REASON`.
|
||||||
|
*
|
||||||
|
* #3304 wired four representative paths (unknown top-level command, unknown
|
||||||
|
* intel subcommand, missing --pick value, --version flag). The rest fell
|
||||||
|
* through to `ERROR_REASON.UNKNOWN`. This file locks the post-#3310 contract:
|
||||||
|
*
|
||||||
|
* - Every "Unknown <subsystem> subcommand" emits reason: "sdk_unknown_command".
|
||||||
|
* - Every "Usage: ..." / missing-required-arg path emits reason: "usage".
|
||||||
|
*
|
||||||
|
* All assertions parse stderr via JSON.parse — never `.includes()` — per the
|
||||||
|
* #2974 / CONTRIBUTING.md "Prohibited: Raw Text Matching" rule.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||||
|
|
||||||
|
// Run gsd-tools with GSD_JSON_ERRORS=1 (env-var activation, exercises the
|
||||||
|
// path #3304 added alongside the --json-errors flag) and parse the
|
||||||
|
// structured stderr. Returns the parsed object; throws if stderr is not JSON.
|
||||||
|
function runJsonErrors(args, tmpDir, env = {}) {
|
||||||
|
const result = runGsdTools(args, tmpDir, { ...env, GSD_JSON_ERRORS: '1' });
|
||||||
|
assert.strictEqual(result.success, false,
|
||||||
|
`Expected failure with GSD_JSON_ERRORS=1 for args: ${args.join(' ')}\n` +
|
||||||
|
`stdout: ${result.output}\nstderr: ${result.error}`);
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(result.error);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(
|
||||||
|
`GSD_JSON_ERRORS=1 must emit valid JSON on stderr.\n` +
|
||||||
|
`Args: ${args.join(' ')}\nstderr: ${result.error}\nparse error: ${e.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert the typed-IR contract: object shape + reason. Keeps the per-test
|
||||||
|
// boilerplate minimal so each error-path test reads as a single fact.
|
||||||
|
function assertTypedError(parsed, expectedReason, label) {
|
||||||
|
assert.strictEqual(parsed.ok, false,
|
||||||
|
`${label}: error object must have ok: false`);
|
||||||
|
assert.strictEqual(parsed.reason, expectedReason,
|
||||||
|
`${label}: reason must be "${expectedReason}", got: ${parsed.reason}`);
|
||||||
|
assert.ok(typeof parsed.message === 'string' && parsed.message.length > 0,
|
||||||
|
`${label}: message must be a non-empty string`);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('feat #3310: typed ERROR_REASON codes on remaining error paths', () => {
|
||||||
|
let tmpDir;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = createTempProject();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup(tmpDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Unknown <subsystem> subcommand → SDK_UNKNOWN_COMMAND ────────────────
|
||||||
|
// Each of these used to fall through to reason: "unknown" before #3310.
|
||||||
|
|
||||||
|
test('unknown template subcommand → sdk_unknown_command', () => {
|
||||||
|
const parsed = runJsonErrors(['template', 'bogus-subcommand-xyzzy'], tmpDir);
|
||||||
|
assertTypedError(parsed, 'sdk_unknown_command', 'template');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown frontmatter subcommand → sdk_unknown_command', () => {
|
||||||
|
// frontmatter expects subcommand at args[1] and file at args[2]; pass a
|
||||||
|
// bogus subcommand with a placeholder file so we definitely reach the
|
||||||
|
// unknown-subcommand branch, not an earlier validation.
|
||||||
|
const parsed = runJsonErrors(
|
||||||
|
['frontmatter', 'bogus-subcommand-xyzzy', 'placeholder.md'],
|
||||||
|
tmpDir
|
||||||
|
);
|
||||||
|
assertTypedError(parsed, 'sdk_unknown_command', 'frontmatter');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown requirements subcommand → sdk_unknown_command', () => {
|
||||||
|
const parsed = runJsonErrors(['requirements', 'bogus-subcommand-xyzzy'], tmpDir);
|
||||||
|
assertTypedError(parsed, 'sdk_unknown_command', 'requirements');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown milestone subcommand → sdk_unknown_command', () => {
|
||||||
|
const parsed = runJsonErrors(['milestone', 'bogus-subcommand-xyzzy'], tmpDir);
|
||||||
|
assertTypedError(parsed, 'sdk_unknown_command', 'milestone');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown uat subcommand → sdk_unknown_command', () => {
|
||||||
|
const parsed = runJsonErrors(['uat', 'bogus-subcommand-xyzzy'], tmpDir);
|
||||||
|
assertTypedError(parsed, 'sdk_unknown_command', 'uat');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown todo subcommand → sdk_unknown_command', () => {
|
||||||
|
const parsed = runJsonErrors(['todo', 'bogus-subcommand-xyzzy'], tmpDir);
|
||||||
|
assertTypedError(parsed, 'sdk_unknown_command', 'todo');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown workstream subcommand → sdk_unknown_command', () => {
|
||||||
|
const parsed = runJsonErrors(['workstream', 'bogus-subcommand-xyzzy'], tmpDir);
|
||||||
|
assertTypedError(parsed, 'sdk_unknown_command', 'workstream');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown graphify subcommand → sdk_unknown_command', () => {
|
||||||
|
const parsed = runJsonErrors(['graphify', 'bogus-subcommand-xyzzy'], tmpDir);
|
||||||
|
assertTypedError(parsed, 'sdk_unknown_command', 'graphify');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown learnings subcommand → sdk_unknown_command', () => {
|
||||||
|
const parsed = runJsonErrors(['learnings', 'bogus-subcommand-xyzzy'], tmpDir);
|
||||||
|
assertTypedError(parsed, 'sdk_unknown_command', 'learnings');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Missing required positional/flag values → USAGE ─────────────────────
|
||||||
|
// These previously emitted reason: "unknown" because the second argument
|
||||||
|
// to error() was absent.
|
||||||
|
|
||||||
|
test('missing --cwd value → usage', () => {
|
||||||
|
// The --cwd flag is consumed before the command dispatcher; passing it
|
||||||
|
// bare with no following value triggers the usage error at L253/L258.
|
||||||
|
const parsed = runJsonErrors(['--cwd'], tmpDir);
|
||||||
|
assertTypedError(parsed, 'usage', '--cwd missing value');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid --cwd directory → usage', () => {
|
||||||
|
// --cwd <nonexistent-path> hits the existsSync / isDirectory check at L264.
|
||||||
|
const parsed = runJsonErrors(
|
||||||
|
['--cwd', '/this/path/should/not/exist/anywhere/xyzzy', 'state', 'load'],
|
||||||
|
tmpDir
|
||||||
|
);
|
||||||
|
assertTypedError(parsed, 'usage', 'invalid --cwd directory');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('intel query missing term → usage', () => {
|
||||||
|
const parsed = runJsonErrors(['intel', 'query'], tmpDir);
|
||||||
|
assertTypedError(parsed, 'usage', 'intel query missing term');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('intel patch-meta missing file path → usage', () => {
|
||||||
|
const parsed = runJsonErrors(['intel', 'patch-meta'], tmpDir);
|
||||||
|
assertTypedError(parsed, 'usage', 'intel patch-meta missing file');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('intel extract-exports missing file path → usage', () => {
|
||||||
|
const parsed = runJsonErrors(['intel', 'extract-exports'], tmpDir);
|
||||||
|
assertTypedError(parsed, 'usage', 'intel extract-exports missing file');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('graphify query missing term → usage', () => {
|
||||||
|
const parsed = runJsonErrors(['graphify', 'query'], tmpDir);
|
||||||
|
assertTypedError(parsed, 'usage', 'graphify query missing term');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('learnings query missing --tag → usage', () => {
|
||||||
|
const parsed = runJsonErrors(['learnings', 'query'], tmpDir);
|
||||||
|
assertTypedError(parsed, 'usage', 'learnings query missing --tag');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('learnings prune missing --older-than → usage', () => {
|
||||||
|
const parsed = runJsonErrors(['learnings', 'prune'], tmpDir);
|
||||||
|
assertTypedError(parsed, 'usage', 'learnings prune missing --older-than');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('learnings delete missing id → usage', () => {
|
||||||
|
const parsed = runJsonErrors(['learnings', 'delete'], tmpDir);
|
||||||
|
assertTypedError(parsed, 'usage', 'learnings delete missing id');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extract-messages missing project arg → usage', () => {
|
||||||
|
// L877 — args[1] is undefined or starts with '--'.
|
||||||
|
const parsed = runJsonErrors(['extract-messages'], tmpDir);
|
||||||
|
assertTypedError(parsed, 'usage', 'extract-messages missing project');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('write-profile missing --input → usage', () => {
|
||||||
|
const parsed = runJsonErrors(['write-profile'], tmpDir);
|
||||||
|
assertTypedError(parsed, 'usage', 'write-profile missing --input');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detect-custom-files missing --config-dir → usage', () => {
|
||||||
|
const parsed = runJsonErrors(['detect-custom-files'], tmpDir);
|
||||||
|
assertTypedError(parsed, 'usage', 'detect-custom-files missing --config-dir');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detect-custom-files invalid --config-dir → usage', () => {
|
||||||
|
const parsed = runJsonErrors(
|
||||||
|
['detect-custom-files', '--config-dir', '/nonexistent/path/xyzzy'],
|
||||||
|
tmpDir
|
||||||
|
);
|
||||||
|
assertTypedError(parsed, 'usage', 'detect-custom-files invalid --config-dir');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Shape regression guard: every newly-typed path emits the canonical
|
||||||
|
// {ok, reason, message} object — no leakage of reason: "unknown". ────
|
||||||
|
|
||||||
|
test('every remaining typed path emits the canonical {ok, reason, message} shape', () => {
|
||||||
|
const probes = [
|
||||||
|
['template', 'bogus'],
|
||||||
|
['frontmatter', 'bogus', 'placeholder.md'],
|
||||||
|
['requirements', 'bogus'],
|
||||||
|
['milestone', 'bogus'],
|
||||||
|
['uat', 'bogus'],
|
||||||
|
['todo', 'bogus'],
|
||||||
|
['workstream', 'bogus'],
|
||||||
|
['graphify', 'bogus'],
|
||||||
|
['learnings', 'bogus'],
|
||||||
|
['intel', 'query'],
|
||||||
|
['extract-messages'],
|
||||||
|
['write-profile'],
|
||||||
|
['detect-custom-files'],
|
||||||
|
];
|
||||||
|
for (const args of probes) {
|
||||||
|
const parsed = runJsonErrors(args, tmpDir);
|
||||||
|
const keys = Object.keys(parsed).sort();
|
||||||
|
assert.deepStrictEqual(keys, ['message', 'ok', 'reason'],
|
||||||
|
`args ${args.join(' ')}: keys must be exactly {ok,reason,message}, got ${keys.join(',')}`);
|
||||||
|
assert.notStrictEqual(parsed.reason, 'unknown',
|
||||||
|
`args ${args.join(' ')}: reason must be a typed code, not the fallback "unknown"`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
// Folded from tests/bug-853-bg-dispatch-runtime-gating.test.cjs — consolidation epic #1969 (B2 #1971)
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const { describe: __foldDescribe } = require('node:test');
|
||||||
|
__foldDescribe("folded:bug-853-bg-dispatch-runtime-gating (consolidation epic #1969 B2 #1971)", () => {
|
||||||
|
'use strict';
|
||||||
|
/**
|
||||||
|
* Regression guard — bug(#853): /gsd-manager and /gsd-autonomous --interactive
|
||||||
|
* silently skipped worktree isolation + independent verification because they
|
||||||
|
* dispatched Plan/Execute via Agent(run_in_background=true). On Claude Code a
|
||||||
|
* backgrounded agent has no Agent/Task tool, so it cannot spawn the nested
|
||||||
|
* subagents (worktree executors, plan-checker, verifier). The workflows must
|
||||||
|
* now resolve dispatch capability from the registry (#1708) and run inline
|
||||||
|
* everywhere except runtimes where dispatch.background && dispatch.backgroundDispatch
|
||||||
|
* are both true (currently: codex, cursor).
|
||||||
|
*
|
||||||
|
* Phase B (#1708): the prose `RUNTIME === 'codex'` rule is graduated to a typed
|
||||||
|
* `gsd_run query dispatch-should-flatten` query backed by shouldFlattenDispatch()
|
||||||
|
* from host-integration.cjs and the documentation-sourced capability registry.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { describe, test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { createTempProject, cleanup: cleanupDir, runGsdTools } = require('./helpers.cjs');
|
||||||
|
|
||||||
|
const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows');
|
||||||
|
// allow-test-rule: source-text-is-the-product (see #1708)
|
||||||
|
const MANAGER = fs.readFileSync(path.join(WORKFLOWS_DIR, 'manager.md'), 'utf8');
|
||||||
|
// allow-test-rule: source-text-is-the-product (see #1708)
|
||||||
|
const AUTONOMOUS = fs.readFileSync(path.join(WORKFLOWS_DIR, 'autonomous.md'), 'utf8');
|
||||||
|
|
||||||
|
describe('bug-853 — manager/autonomous gate background dispatch by runtime', () => {
|
||||||
|
test('manager.md resolves dispatch-should-flatten before dispatching plan/execute', () => {
|
||||||
|
// Two dispatch sites (plan + execute), each must use dispatch-should-flatten.
|
||||||
|
// allow-test-rule: source-text-is-the-product (see #1708)
|
||||||
|
const matches = MANAGER.match(/dispatch-should-flatten/g) || [];
|
||||||
|
assert.ok(matches.length >= 2, 'manager.md must use dispatch-should-flatten for both plan and execute dispatch');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('manager.md documents why most runtimes cannot background-dispatch', () => {
|
||||||
|
// Accept both old singular form (backgrounded agent has no) and new plural form (backgrounded agents have no)
|
||||||
|
// allow-test-rule: source-text-is-the-product (see #1708)
|
||||||
|
assert.match(MANAGER, /backgrounded agents? ha(?:s|ve) no `Agent`\/`Task` tool/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('manager.md gates background dispatch on FLATTEN=false and runs plan/execute inline otherwise', () => {
|
||||||
|
// Background path uses FLATTEN is false
|
||||||
|
// allow-test-rule: source-text-is-the-product (see #1708)
|
||||||
|
assert.match(MANAGER, /If `FLATTEN` is `false`[\s\S]{0,400}?run_in_background=true/);
|
||||||
|
// Inline is the default/else branch for plan — anchored on FLATTEN=true language (not runtime name)
|
||||||
|
assert.match(
|
||||||
|
MANAGER,
|
||||||
|
/Otherwise[\s\S]{0,100}?`FLATTEN`[\s\S]{0,400}?Skill\(skill="gsd-plan-phase"/,
|
||||||
|
);
|
||||||
|
// Inline is the default/else branch for execute — anchored on FLATTEN=true language (not runtime name)
|
||||||
|
assert.match(
|
||||||
|
MANAGER,
|
||||||
|
/Otherwise[\s\S]{0,100}?`FLATTEN`[\s\S]{0,400}?Skill\(skill="gsd-execute-phase"/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('manager.md compound action preamble uses FLATTEN language (not hardcoded runtime names)', () => {
|
||||||
|
// allow-test-rule: source-text-is-the-product (see #1708)
|
||||||
|
const compoundActionSection = MANAGER.match(
|
||||||
|
/### Compound Action \(background \+ inline\)[\s\S]*?Inline verification:/,
|
||||||
|
);
|
||||||
|
assert.ok(compoundActionSection, 'manager.md must document compound action runtime dispatch');
|
||||||
|
|
||||||
|
// Must gate on FLATTEN being false (not runtime name)
|
||||||
|
assert.match(
|
||||||
|
compoundActionSection[0],
|
||||||
|
/If `FLATTEN` is `false`[\s\S]{0,400}?Spawn all background agents first[\s\S]{0,300}?plan\/execute/,
|
||||||
|
);
|
||||||
|
// Otherwise / inline branch must reference FLATTEN being true
|
||||||
|
assert.match(
|
||||||
|
compoundActionSection[0],
|
||||||
|
/Otherwise[\s\S]{0,260}?`FLATTEN`[\s\S]{0,260}?`true`[\s\S]{0,260}?inline/,
|
||||||
|
);
|
||||||
|
// Must NOT still hardcode "On Codex:" in this section
|
||||||
|
assert.doesNotMatch(
|
||||||
|
compoundActionSection[0],
|
||||||
|
/\*\*On Codex:\*\*/,
|
||||||
|
);
|
||||||
|
// Must NOT still hardcode "On Claude Code or any other non-Codex runtime:"
|
||||||
|
assert.doesNotMatch(
|
||||||
|
compoundActionSection[0],
|
||||||
|
/On Claude Code or any other non-Codex runtime:/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('autonomous.md gates interactive background dispatch using dispatch-should-flatten', () => {
|
||||||
|
// Two dispatch sites (3b plan + 3c execute), each must use dispatch-should-flatten.
|
||||||
|
// allow-test-rule: source-text-is-the-product (see #1708)
|
||||||
|
const autoFlattenMatches = AUTONOMOUS.match(/dispatch-should-flatten/g) || [];
|
||||||
|
assert.ok(autoFlattenMatches.length >= 2, 'autonomous.md must use dispatch-should-flatten in both 3b (plan) and 3c (execute) interactive branches');
|
||||||
|
// Accept both old singular form (backgrounded agent has no) and new plural form (backgrounded agents have no)
|
||||||
|
assert.match(AUTONOMOUS, /backgrounded agents? ha(?:s|ve) no `Agent`\/`Task` tool/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('autonomous.md gates interactive background dispatch on FLATTEN=false; runs plan/execute inline otherwise', () => {
|
||||||
|
// Background block: run_in_background=true appears within the FLATTEN=false branch and gsd-plan-phase is nearby
|
||||||
|
// allow-test-rule: source-text-is-the-product (see #1708)
|
||||||
|
assert.match(AUTONOMOUS, /If `FLATTEN` is `false`[\s\S]{0,1200}?run_in_background=true[\s\S]{0,600}?gsd-plan-phase/);
|
||||||
|
// Background block: run_in_background=true appears within the FLATTEN=false branch and gsd-execute-phase is nearby
|
||||||
|
assert.match(AUTONOMOUS, /If `FLATTEN` is `false`[\s\S]{0,3000}?run_in_background=true[\s\S]{0,200}?gsd-execute-phase/);
|
||||||
|
// Inline is the otherwise/else branch for plan — anchored on FLATTEN=true language (not runtime name)
|
||||||
|
assert.match(
|
||||||
|
AUTONOMOUS,
|
||||||
|
/Otherwise[\s\S]{0,100}?`FLATTEN`[\s\S]{0,400}?Skill\(skill="gsd-plan-phase"/,
|
||||||
|
);
|
||||||
|
// Inline is the otherwise/else branch for execute — anchored on FLATTEN=true language (not runtime name)
|
||||||
|
assert.match(
|
||||||
|
AUTONOMOUS,
|
||||||
|
/Otherwise[\s\S]{0,100}?`FLATTEN`[\s\S]{0,400}?Skill\(skill="gsd-execute-phase"/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dispatch-should-flatten query — behavioral', () => {
|
||||||
|
// #853 / #1708: The typed query replaces prose-level RUNTIME===codex checks.
|
||||||
|
// shouldFlattenDispatch returns false only when both dispatch.background AND
|
||||||
|
// dispatch.backgroundDispatch are true in the capability registry.
|
||||||
|
//
|
||||||
|
// Registry values (from host-integration-capability-matrix.md):
|
||||||
|
// codex: background=true, backgroundDispatch=true → shouldFlatten=false (may background)
|
||||||
|
// claude: background=true, backgroundDispatch=false → shouldFlatten=true (must inline)
|
||||||
|
// cursor: background=true, backgroundDispatch=true → shouldFlatten=false (may background)
|
||||||
|
// unknown: no entry → fail-closed → shouldFlatten=true (must inline)
|
||||||
|
|
||||||
|
test('runtime=codex → shouldFlatten=false (background dispatch safe)', () => {
|
||||||
|
const tmpDir = createTempProject();
|
||||||
|
try {
|
||||||
|
const result = runGsdTools(['query', 'dispatch-should-flatten', '--raw'], tmpDir, {
|
||||||
|
GSD_RUNTIME: 'codex',
|
||||||
|
});
|
||||||
|
assert.ok(result.success, `Expected success, got error: ${result.error}`);
|
||||||
|
assert.strictEqual(result.output, 'false', `codex should return false (may background), got: ${result.output}`);
|
||||||
|
} finally {
|
||||||
|
cleanupDir(tmpDir);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runtime=claude → shouldFlatten=true (must inline)', () => {
|
||||||
|
const tmpDir = createTempProject();
|
||||||
|
try {
|
||||||
|
const result = runGsdTools(['query', 'dispatch-should-flatten', '--raw'], tmpDir, {
|
||||||
|
GSD_RUNTIME: 'claude',
|
||||||
|
});
|
||||||
|
assert.ok(result.success, `Expected success, got error: ${result.error}`);
|
||||||
|
assert.strictEqual(result.output, 'true', `claude should return true (must inline), got: ${result.output}`);
|
||||||
|
} finally {
|
||||||
|
cleanupDir(tmpDir);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runtime=cursor → shouldFlatten=false (background dispatch safe)', () => {
|
||||||
|
const tmpDir = createTempProject();
|
||||||
|
try {
|
||||||
|
const result = runGsdTools(['query', 'dispatch-should-flatten', '--raw'], tmpDir, {
|
||||||
|
GSD_RUNTIME: 'cursor',
|
||||||
|
});
|
||||||
|
assert.ok(result.success, `Expected success, got error: ${result.error}`);
|
||||||
|
assert.strictEqual(result.output, 'false', `cursor should return false (may background), got: ${result.output}`);
|
||||||
|
} finally {
|
||||||
|
cleanupDir(tmpDir);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown runtime → shouldFlatten=true (fail-closed → must inline)', () => {
|
||||||
|
// An unknown runtime has no registry entry → dispatch is null → fail-closed to true.
|
||||||
|
const tmpDir = createTempProject();
|
||||||
|
try {
|
||||||
|
const result = runGsdTools(['query', 'dispatch-should-flatten', '--raw'], tmpDir, {
|
||||||
|
GSD_RUNTIME: 'unknown-runtime-xyz',
|
||||||
|
});
|
||||||
|
// The query must succeed (exit 0) even for unknown runtimes — fail-closed not crash-closed.
|
||||||
|
assert.ok(result.success, `Expected success (fail-closed), got error: ${result.error}`);
|
||||||
|
assert.strictEqual(result.output, 'true', `unknown runtime should return true (fail-closed), got: ${result.output}`);
|
||||||
|
} finally {
|
||||||
|
cleanupDir(tmpDir);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('--json flag returns structured { runtime, shouldFlatten, dispatch }', () => {
|
||||||
|
const tmpDir = createTempProject();
|
||||||
|
try {
|
||||||
|
const result = runGsdTools(['query', 'dispatch-should-flatten', '--json'], tmpDir, {
|
||||||
|
GSD_RUNTIME: 'codex',
|
||||||
|
});
|
||||||
|
assert.ok(result.success, `Expected success, got error: ${result.error}`);
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(result.output);
|
||||||
|
} catch {
|
||||||
|
assert.fail(`Expected valid JSON output, got: ${result.output}`);
|
||||||
|
}
|
||||||
|
assert.strictEqual(parsed.runtime, 'codex');
|
||||||
|
assert.strictEqual(parsed.shouldFlatten, false);
|
||||||
|
assert.ok(parsed.dispatch !== null && typeof parsed.dispatch === 'object', 'dispatch should be an object');
|
||||||
|
assert.strictEqual(parsed.dispatch.backgroundDispatch, true);
|
||||||
|
} finally {
|
||||||
|
cleanupDir(tmpDir);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('config.runtime takes precedence when GSD_RUNTIME not set', () => {
|
||||||
|
// GSD_RUNTIME > config.runtime > 'claude'
|
||||||
|
// Write config.json with runtime=codex; no GSD_RUNTIME override.
|
||||||
|
const tmpDir = createTempProject();
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(tmpDir, '.planning', 'config.json'),
|
||||||
|
JSON.stringify({ runtime: 'codex' }),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
// Override GSD_RUNTIME to '' (empty string) so any ambient value is cleared.
|
||||||
|
// resolveRuntimeNameFromCandidates treats empty string as absent (normalizes
|
||||||
|
// to '' which is falsy → skipped → falls through to config.runtime=codex).
|
||||||
|
// This is the only way to suppress an ambient GSD_RUNTIME since runGsdTools
|
||||||
|
// merges { ...process.env, ...TEST_ENV_BASE, ...env } — passing '' as the
|
||||||
|
// override overwrites the ambient value at the correct merge position.
|
||||||
|
const result = runGsdTools(['query', 'dispatch-should-flatten', '--raw'], tmpDir, {
|
||||||
|
GSD_RUNTIME: '',
|
||||||
|
});
|
||||||
|
// config.runtime=codex with GSD_RUNTIME cleared → codex backgrounds → shouldFlatten=false
|
||||||
|
assert.ok(result.success, `Expected success, got error: ${result.error}`);
|
||||||
|
assert.strictEqual(result.output, 'false', `config.runtime=codex (GSD_RUNTIME cleared) should return false (may background), got: ${result.output}`);
|
||||||
|
} finally {
|
||||||
|
cleanupDir(tmpDir);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1462,3 +1462,585 @@ describe('config-set workflow.test_command (#1216)', () => {
|
|||||||
assert.strictEqual(config.workflow?.build_command, 'npm run build', 'value must be persisted');
|
assert.strictEqual(config.workflow?.build_command, 'npm run build', 'value must be persisted');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
// Folded from tests/bug-2601-inherit-model-profile.test.cjs — consolidation epic #1969 (B2 #1971)
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const { describe: __foldDescribe } = require('node:test');
|
||||||
|
__foldDescribe("folded:bug-2601-inherit-model-profile (consolidation epic #1969 B2 #1971)", () => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression tests for bug #2601
|
||||||
|
*
|
||||||
|
* `config-set-model-profile inherit` (and `config-set model_profile inherit`)
|
||||||
|
* was rejected by the validator even though the runtime accepts 'inherit' as a
|
||||||
|
* valid model_profile value meaning "inherit from parent configuration".
|
||||||
|
*
|
||||||
|
* Root cause: VALID_PROFILES in model-profiles.cjs is derived from
|
||||||
|
* Object.keys(MODEL_PROFILES['gsd-planner']), which does not include 'inherit'.
|
||||||
|
* cmdConfigSetModelProfile() rejects any value not in VALID_PROFILES.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { describe, test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||||
|
|
||||||
|
describe('bug #2601: config-set-model-profile accepts inherit', () => {
|
||||||
|
test('config-set-model-profile inherit succeeds', (t) => {
|
||||||
|
const tmpDir = createTempProject();
|
||||||
|
t.after(() => cleanup(tmpDir));
|
||||||
|
const result = runGsdTools(['config-set-model-profile', 'inherit'], tmpDir);
|
||||||
|
assert.ok(result.success, `should accept inherit: ${result.error}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('config-set model_profile inherit succeeds', (t) => {
|
||||||
|
const tmpDir = createTempProject();
|
||||||
|
t.after(() => cleanup(tmpDir));
|
||||||
|
const result = runGsdTools(['config-set', 'model_profile', 'inherit'], tmpDir);
|
||||||
|
assert.ok(result.success, `config-set model_profile inherit should succeed: ${result.error}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('config-set-model-profile inherit writes inherit to config', (t) => {
|
||||||
|
const tmpDir = createTempProject();
|
||||||
|
t.after(() => cleanup(tmpDir));
|
||||||
|
runGsdTools(['config-set-model-profile', 'inherit'], tmpDir);
|
||||||
|
const getResult = runGsdTools(['config-get', 'model_profile'], tmpDir);
|
||||||
|
assert.ok(getResult.success, `config-get should succeed: ${getResult.error}`);
|
||||||
|
assert.strictEqual(JSON.parse(getResult.output), 'inherit');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('config-set-model-profile still rejects truly invalid profiles', (t) => {
|
||||||
|
const tmpDir = createTempProject();
|
||||||
|
t.after(() => cleanup(tmpDir));
|
||||||
|
const result = runGsdTools(['config-set-model-profile', 'not-a-real-profile'], tmpDir);
|
||||||
|
assert.ok(!result.success, 'should reject invalid profiles');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
// Folded from tests/bug-3197-gsd-tools-config-whitelist.test.cjs — consolidation epic #1969 (B2 #1971)
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const { describe: __foldDescribe } = require('node:test');
|
||||||
|
__foldDescribe("folded:bug-3197-gsd-tools-config-whitelist (consolidation epic #1969 B2 #1971)", () => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression test for #3197 — gsd-tools config-set rejects workflow._auto_chain_active.
|
||||||
|
*
|
||||||
|
* Root cause: RUNTIME_STATE_KEYS was added to sdk/src/query/config-schema.ts in #3162
|
||||||
|
* but not to gsd-core/bin/lib/config-schema.cjs, so gsd-tools.cjs users still hit
|
||||||
|
* "Unknown config key" when setting workflow._auto_chain_active.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { describe, test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
|
||||||
|
|
||||||
|
describe('#3197 — gsd-tools.cjs config-set workflow._auto_chain_active', () => {
|
||||||
|
test('config-set workflow._auto_chain_active true succeeds via gsd-tools.cjs (CJS path)', (t) => {
|
||||||
|
const tmpDir = createTempProject();
|
||||||
|
t.after(() => cleanup(tmpDir));
|
||||||
|
|
||||||
|
const result = runGsdTools(['config-set', 'workflow._auto_chain_active', 'true'], tmpDir);
|
||||||
|
assert.ok(
|
||||||
|
result.success,
|
||||||
|
`config-set workflow._auto_chain_active true should succeed, got:\nstdout: ${result.output}\nstderr: ${result.error}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('config-set workflow._auto_chain_active true writes value to config.json', (t) => {
|
||||||
|
const tmpDir = createTempProject();
|
||||||
|
t.after(() => cleanup(tmpDir));
|
||||||
|
|
||||||
|
runGsdTools(['config-set', 'workflow._auto_chain_active', 'true'], tmpDir);
|
||||||
|
|
||||||
|
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
||||||
|
assert.ok(fs.existsSync(configPath), '.planning/config.json must exist after config-set');
|
||||||
|
|
||||||
|
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||||
|
assert.ok(
|
||||||
|
config.workflow !== undefined && config.workflow._auto_chain_active === true,
|
||||||
|
`Expected workflow._auto_chain_active: true in config.json, got: ${JSON.stringify(config)}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('config-set workflow._auto_chain_active false writes false to config.json', (t) => {
|
||||||
|
const tmpDir = createTempProject();
|
||||||
|
t.after(() => cleanup(tmpDir));
|
||||||
|
|
||||||
|
runGsdTools(['config-set', 'workflow._auto_chain_active', 'false'], tmpDir);
|
||||||
|
|
||||||
|
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
||||||
|
assert.ok(fs.existsSync(configPath), '.planning/config.json must exist after config-set');
|
||||||
|
|
||||||
|
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||||
|
assert.ok(
|
||||||
|
config.workflow !== undefined && config.workflow._auto_chain_active === false,
|
||||||
|
`Expected workflow._auto_chain_active: false in config.json, got: ${JSON.stringify(config)}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
// Folded from tests/bug-3086-git-create-tag-config-gate.test.cjs — consolidation epic #1969 (B2 #1971)
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const { describe: __foldDescribe } = require('node:test');
|
||||||
|
__foldDescribe("folded:bug-3086-git-create-tag-config-gate (consolidation epic #1969 B2 #1971)", () => {
|
||||||
|
// allow-test-rule: workflow-markdown-is-the-runtime-contract (see #3086)
|
||||||
|
// Justification: complete-milestone.md IS the runtime — the agent reads and
|
||||||
|
// follows it directly. Asserting the <config-check> block is present in the
|
||||||
|
// markdown is the only way to verify the gate is wired. Per CONTEXT.md L611.
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #3086 — git.create_tag config gate for milestone tagging.
|
||||||
|
*
|
||||||
|
* Tests:
|
||||||
|
* A. Default value: fresh project returns `true` for git.create_tag
|
||||||
|
* B. config-set false → config-get returns false
|
||||||
|
* C. Invalid value (e.g. "maybe") is rejected by schema validator
|
||||||
|
* D. complete-milestone.md workflow contains the <config-check> gate for git.create_tag
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { describe, test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
|
||||||
|
|
||||||
|
const WORKFLOW_PATH = path.join(
|
||||||
|
__dirname,
|
||||||
|
'..',
|
||||||
|
'gsd-core',
|
||||||
|
'workflows',
|
||||||
|
'complete-milestone.md',
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('#3086: git.create_tag config key', () => {
|
||||||
|
test('A. fresh project: config-get git.create_tag returns true (default)', (t) => {
|
||||||
|
const tmpDir = createTempProject('gsd-3086-default-');
|
||||||
|
t.after(() => cleanup(tmpDir));
|
||||||
|
|
||||||
|
const result = runGsdTools(['config-get', 'git.create_tag'], tmpDir, { HOME: tmpDir });
|
||||||
|
assert.ok(result.success, `config-get git.create_tag failed:\n${result.error}`);
|
||||||
|
assert.strictEqual(
|
||||||
|
result.output.trim(),
|
||||||
|
'true',
|
||||||
|
`Expected default value 'true', got: '${result.output.trim()}'`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('B. config-set git.create_tag false → config-get returns false', (t) => {
|
||||||
|
const tmpDir = createTempProject('gsd-3086-set-false-');
|
||||||
|
t.after(() => cleanup(tmpDir));
|
||||||
|
|
||||||
|
const setResult = runGsdTools(['config-set', 'git.create_tag', 'false'], tmpDir, {
|
||||||
|
HOME: tmpDir,
|
||||||
|
});
|
||||||
|
assert.ok(setResult.success, `config-set git.create_tag false failed:\n${setResult.error}`);
|
||||||
|
|
||||||
|
const getResult = runGsdTools(['config-get', 'git.create_tag'], tmpDir, { HOME: tmpDir });
|
||||||
|
assert.ok(getResult.success, `config-get after set failed:\n${getResult.error}`);
|
||||||
|
assert.strictEqual(
|
||||||
|
getResult.output.trim(),
|
||||||
|
'false',
|
||||||
|
`Expected 'false' after set, got: '${getResult.output.trim()}'`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('C. config-set git.create_tag with invalid value "maybe" is rejected', (t) => {
|
||||||
|
const tmpDir = createTempProject('gsd-3086-invalid-');
|
||||||
|
t.after(() => cleanup(tmpDir));
|
||||||
|
|
||||||
|
const result = runGsdTools(['config-set', 'git.create_tag', 'maybe'], tmpDir, {
|
||||||
|
HOME: tmpDir,
|
||||||
|
});
|
||||||
|
assert.ok(
|
||||||
|
!result.success,
|
||||||
|
`Expected config-set to fail for invalid value "maybe", but it succeeded`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('D. complete-milestone.md contains <config-check> gate for git.create_tag', () => {
|
||||||
|
const content = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||||
|
assert.ok(
|
||||||
|
content.includes('git.create_tag'),
|
||||||
|
'complete-milestone.md must reference git.create_tag in a <config-check> block',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
content.includes('<config-check>'),
|
||||||
|
'complete-milestone.md must have a <config-check> block in the git_tag step',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
// Folded from tests/feat-3167-ship-pr-body-sections.test.cjs — consolidation epic #1969 (B2 #1971)
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const { describe: __foldDescribe } = require('node:test');
|
||||||
|
__foldDescribe("folded:feat-3167-ship-pr-body-sections (consolidation epic #1969 B2 #1971)", () => {
|
||||||
|
/**
|
||||||
|
* Regression tests for issue #3167: configurable /gsd-ship PR body sections.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// allow-test-rule: source-text-is-the-product (see #3167)
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { describe, test, afterEach } = require('node:test');
|
||||||
|
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||||
|
|
||||||
|
const repoRoot = path.resolve(__dirname, '..');
|
||||||
|
const tmpDirs = [];
|
||||||
|
|
||||||
|
function readRepoFile(relativePath) {
|
||||||
|
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeProject() {
|
||||||
|
const tmpDir = createTempProject('gsd-3167-');
|
||||||
|
tmpDirs.push(tmpDir);
|
||||||
|
return tmpDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
while (tmpDirs.length) {
|
||||||
|
cleanup(tmpDirs.pop());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ship.pr_body_sections config (#3167)', () => {
|
||||||
|
test('CLI config-set accepts additional PR body section arrays', () => {
|
||||||
|
const cwd = makeProject();
|
||||||
|
const value = JSON.stringify([
|
||||||
|
{
|
||||||
|
heading: 'Risks & Rollback',
|
||||||
|
enabled: true,
|
||||||
|
source: 'PLAN.md ## Risks || PLAN.md ## Rollback',
|
||||||
|
fallback: '- Rollback: revert this PR.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: 'Stakeholder Sign-off',
|
||||||
|
enabled: false,
|
||||||
|
template: '- Product owner: pending',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = runGsdTools(['config-set', 'ship.pr_body_sections', value, '--raw'], cwd, { HOME: cwd });
|
||||||
|
|
||||||
|
assert.equal(result.success, true, result.error);
|
||||||
|
const config = JSON.parse(fs.readFileSync(path.join(cwd, '.planning', 'config.json'), 'utf8'));
|
||||||
|
assert.deepEqual(config.ship.pr_body_sections, [
|
||||||
|
{
|
||||||
|
heading: 'Risks & Rollback',
|
||||||
|
enabled: true,
|
||||||
|
source: 'PLAN.md ## Risks || PLAN.md ## Rollback',
|
||||||
|
fallback: '- Rollback: revert this PR.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: 'Stakeholder Sign-off',
|
||||||
|
enabled: false,
|
||||||
|
template: '- Product owner: pending',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CLI config-set rejects malformed PR body section values before writing config', () => {
|
||||||
|
const cwd = makeProject();
|
||||||
|
|
||||||
|
const notArray = runGsdTools(
|
||||||
|
['config-set', 'ship.pr_body_sections', JSON.stringify({ heading: 'Not an array' }), '--raw'],
|
||||||
|
cwd,
|
||||||
|
{ HOME: cwd }
|
||||||
|
);
|
||||||
|
assert.equal(notArray.success, false);
|
||||||
|
assert.match(notArray.error, /ship\.pr_body_sections.*JSON array/);
|
||||||
|
|
||||||
|
const missingHeading = runGsdTools(
|
||||||
|
['config-set', 'ship.pr_body_sections', JSON.stringify([{ fallback: '- Missing heading' }]), '--raw'],
|
||||||
|
cwd,
|
||||||
|
{ HOME: cwd }
|
||||||
|
);
|
||||||
|
assert.equal(missingHeading.success, false);
|
||||||
|
assert.match(missingHeading.error, /heading/);
|
||||||
|
|
||||||
|
const invalidEnabled = runGsdTools(
|
||||||
|
['config-set', 'ship.pr_body_sections', JSON.stringify([{ heading: 'Toggle', enabled: 'yes', fallback: '- item' }]), '--raw'],
|
||||||
|
cwd,
|
||||||
|
{ HOME: cwd }
|
||||||
|
);
|
||||||
|
assert.equal(invalidEnabled.success, false);
|
||||||
|
assert.match(invalidEnabled.error, /enabled/);
|
||||||
|
|
||||||
|
assert.equal(fs.existsSync(path.join(cwd, '.planning', 'config.json')), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CLI config-new-project validates onboarded PR body sections before writing config', () => {
|
||||||
|
const cwd = makeProject();
|
||||||
|
const choices = JSON.stringify({
|
||||||
|
ship: {
|
||||||
|
pr_body_sections: [
|
||||||
|
{
|
||||||
|
heading: 'Invalid source',
|
||||||
|
source: 'package.json ## Scripts',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = runGsdTools(['config-new-project', choices], cwd, { HOME: cwd });
|
||||||
|
|
||||||
|
assert.equal(result.success, false);
|
||||||
|
assert.match(result.error, /source must use selectors/);
|
||||||
|
assert.equal(fs.existsSync(path.join(cwd, '.planning', 'config.json')), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ship workflow composes configured sections as append-only extensions', () => {
|
||||||
|
const workflow = readRepoFile('gsd-core/workflows/ship.md');
|
||||||
|
|
||||||
|
assert.match(workflow, /config-get ship\.pr_body_sections --default '\[\]'/);
|
||||||
|
assert.match(workflow, /append-only/i);
|
||||||
|
assert.match(workflow, /enabled.*false/i);
|
||||||
|
assert.match(workflow, /cannot replace/i);
|
||||||
|
assert.match(workflow, /Summary[\s\S]*Changes[\s\S]*Requirements Addressed[\s\S]*Verification[\s\S]*Key Decisions/);
|
||||||
|
assert.match(workflow, /\{phase_number\}[\s\S]*\{phase_name\}[\s\S]*\{phase_dir\}[\s\S]*\{base_branch\}[\s\S]*\{padded_phase\}/);
|
||||||
|
assert.match(workflow, /User Stories & Acceptance Criteria/);
|
||||||
|
assert.match(workflow, /Definition of Done/);
|
||||||
|
assert.match(workflow, /--body-file/);
|
||||||
|
assert.match(workflow, /trap 'rm -f "\$\{PR_BODY_FILE:-\}"' EXIT/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('default config and documentation describe ship.pr_body_sections', () => {
|
||||||
|
const template = JSON.parse(readRepoFile('gsd-core/templates/config.json'));
|
||||||
|
assert.deepEqual(template.ship.pr_body_sections, []);
|
||||||
|
|
||||||
|
const docs = readRepoFile('docs/CONFIGURATION.md');
|
||||||
|
assert.match(docs, /`ship\.pr_body_sections`/);
|
||||||
|
assert.match(docs, /additional PR body sections/i);
|
||||||
|
assert.match(docs, /append-only/i);
|
||||||
|
assert.match(docs, /lean\/agile PRD/i);
|
||||||
|
assert.match(docs, /Definition of Done/);
|
||||||
|
|
||||||
|
const planningConfig = readRepoFile('gsd-core/references/planning-config.md');
|
||||||
|
assert.match(planningConfig, /ship\.pr_body_sections/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('new-project onboarding can seed enabled or disabled PR body sections', () => {
|
||||||
|
const workflow = readRepoFile('gsd-core/workflows/new-project.md');
|
||||||
|
|
||||||
|
assert.match(workflow, /ship\.pr_body_sections/);
|
||||||
|
assert.match(workflow, /enabled.*true/);
|
||||||
|
assert.match(workflow, /enabled.*false/);
|
||||||
|
assert.match(workflow, /User Stories & Acceptance Criteria/);
|
||||||
|
assert.match(workflow, /Risks & Dependencies/);
|
||||||
|
assert.match(workflow, /Success Metrics & Release Criteria/);
|
||||||
|
assert.match(workflow, /Stakeholder Review & Approval/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
// Folded from tests/feat-3210-fallow-schema-enum.test.cjs — consolidation epic #1969 (B2 #1971)
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const { describe: __foldDescribe } = require('node:test');
|
||||||
|
__foldDescribe("folded:feat-3210-fallow-schema-enum (consolidation epic #1969 B2 #1971)", () => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enum validation for code_quality.fallow.scope and code_quality.fallow.profile.
|
||||||
|
*
|
||||||
|
* Fixes H5 from #3424 review: config-set silently accepted invalid enum values
|
||||||
|
* (e.g. scope=fullrepo) and fell through to default behavior. This test asserts
|
||||||
|
* that invalid values are rejected with a helpful error, and valid values pass.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { describe, test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
|
||||||
|
|
||||||
|
describe('feat-3210 / H5: enum validation for code_quality.fallow.scope and .profile', () => {
|
||||||
|
// --- code_quality.fallow.scope ---
|
||||||
|
|
||||||
|
test('config-set code_quality.fallow.scope=fullrepo is REJECTED with helpful error', (t) => {
|
||||||
|
const tmpDir = createTempProject();
|
||||||
|
t.after(() => cleanup(tmpDir));
|
||||||
|
|
||||||
|
const result = runGsdTools(
|
||||||
|
['config-set', 'code_quality.fallow.scope', 'fullrepo'],
|
||||||
|
tmpDir
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
!result.success,
|
||||||
|
'config-set code_quality.fallow.scope=fullrepo must fail, but it succeeded'
|
||||||
|
);
|
||||||
|
const combined = (result.output || '') + (result.error || '');
|
||||||
|
assert.ok(
|
||||||
|
combined.includes('phase') && combined.includes('repo'),
|
||||||
|
`Error message must mention valid values "phase" and "repo", got: ${combined}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('config-set code_quality.fallow.scope=phase is ACCEPTED', (t) => {
|
||||||
|
const tmpDir = createTempProject();
|
||||||
|
t.after(() => cleanup(tmpDir));
|
||||||
|
|
||||||
|
const result = runGsdTools(
|
||||||
|
['config-set', 'code_quality.fallow.scope', 'phase'],
|
||||||
|
tmpDir
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.success,
|
||||||
|
[
|
||||||
|
'config-set code_quality.fallow.scope=phase must succeed,',
|
||||||
|
'stdout: ' + result.output,
|
||||||
|
'stderr: ' + result.error,
|
||||||
|
].join('\n')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('config-set code_quality.fallow.scope=repo is ACCEPTED', (t) => {
|
||||||
|
const tmpDir = createTempProject();
|
||||||
|
t.after(() => cleanup(tmpDir));
|
||||||
|
|
||||||
|
const result = runGsdTools(
|
||||||
|
['config-set', 'code_quality.fallow.scope', 'repo'],
|
||||||
|
tmpDir
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.success,
|
||||||
|
[
|
||||||
|
'config-set code_quality.fallow.scope=repo must succeed,',
|
||||||
|
'stdout: ' + result.output,
|
||||||
|
'stderr: ' + result.error,
|
||||||
|
].join('\n')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('config-set code_quality.fallow.scope=PHASE (wrong case) is REJECTED', (t) => {
|
||||||
|
const tmpDir = createTempProject();
|
||||||
|
t.after(() => cleanup(tmpDir));
|
||||||
|
|
||||||
|
const result = runGsdTools(
|
||||||
|
['config-set', 'code_quality.fallow.scope', 'PHASE'],
|
||||||
|
tmpDir
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
!result.success,
|
||||||
|
'config-set code_quality.fallow.scope=PHASE must fail (values are case-sensitive)'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- code_quality.fallow.profile ---
|
||||||
|
|
||||||
|
test('config-set code_quality.fallow.profile=aggressive is REJECTED with helpful error', (t) => {
|
||||||
|
const tmpDir = createTempProject();
|
||||||
|
t.after(() => cleanup(tmpDir));
|
||||||
|
|
||||||
|
const result = runGsdTools(
|
||||||
|
['config-set', 'code_quality.fallow.profile', 'aggressive'],
|
||||||
|
tmpDir
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
!result.success,
|
||||||
|
'config-set code_quality.fallow.profile=aggressive must fail, but it succeeded'
|
||||||
|
);
|
||||||
|
const combined = (result.output || '') + (result.error || '');
|
||||||
|
assert.ok(
|
||||||
|
combined.includes('minimal') && combined.includes('standard') && combined.includes('strict'),
|
||||||
|
`Error message must mention valid values "minimal", "standard", "strict", got: ${combined}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('config-set code_quality.fallow.profile=minimal is ACCEPTED', (t) => {
|
||||||
|
const tmpDir = createTempProject();
|
||||||
|
t.after(() => cleanup(tmpDir));
|
||||||
|
|
||||||
|
const result = runGsdTools(
|
||||||
|
['config-set', 'code_quality.fallow.profile', 'minimal'],
|
||||||
|
tmpDir
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.success,
|
||||||
|
[
|
||||||
|
'config-set code_quality.fallow.profile=minimal must succeed,',
|
||||||
|
'stdout: ' + result.output,
|
||||||
|
'stderr: ' + result.error,
|
||||||
|
].join('\n')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('config-set code_quality.fallow.profile=standard is ACCEPTED', (t) => {
|
||||||
|
const tmpDir = createTempProject();
|
||||||
|
t.after(() => cleanup(tmpDir));
|
||||||
|
|
||||||
|
const result = runGsdTools(
|
||||||
|
['config-set', 'code_quality.fallow.profile', 'standard'],
|
||||||
|
tmpDir
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.success,
|
||||||
|
[
|
||||||
|
'config-set code_quality.fallow.profile=standard must succeed,',
|
||||||
|
'stdout: ' + result.output,
|
||||||
|
'stderr: ' + result.error,
|
||||||
|
].join('\n')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('config-set code_quality.fallow.profile=strict is ACCEPTED', (t) => {
|
||||||
|
const tmpDir = createTempProject();
|
||||||
|
t.after(() => cleanup(tmpDir));
|
||||||
|
|
||||||
|
const result = runGsdTools(
|
||||||
|
['config-set', 'code_quality.fallow.profile', 'strict'],
|
||||||
|
tmpDir
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.success,
|
||||||
|
[
|
||||||
|
'config-set code_quality.fallow.profile=strict must succeed,',
|
||||||
|
'stdout: ' + result.output,
|
||||||
|
'stderr: ' + result.error,
|
||||||
|
].join('\n')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('config-set code_quality.fallow.profile=unknown is REJECTED', (t) => {
|
||||||
|
const tmpDir = createTempProject();
|
||||||
|
t.after(() => cleanup(tmpDir));
|
||||||
|
|
||||||
|
const result = runGsdTools(
|
||||||
|
['config-set', 'code_quality.fallow.profile', 'unknown'],
|
||||||
|
tmpDir
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
!result.success,
|
||||||
|
'config-set code_quality.fallow.profile=unknown must fail'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,296 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
// allow-test-rule: source-text-is-the-product
|
|
||||||
// Reads .md/.json/.yml product files whose deployed text IS what the
|
|
||||||
// runtime loads — testing text content tests the deployed contract.
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tests for ROADMAP wave dependency surfacing (#2447).
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { test, describe, afterEach } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
|
||||||
|
|
||||||
const PLAN_TEMPLATE = (wave, truths = []) => `---
|
|
||||||
phase: "1"
|
|
||||||
plan: "01-0${wave}"
|
|
||||||
type: standard
|
|
||||||
wave: ${wave}
|
|
||||||
depends_on: []
|
|
||||||
files_modified: []
|
|
||||||
autonomous: true
|
|
||||||
requirements: []
|
|
||||||
must_haves:
|
|
||||||
truths:
|
|
||||||
${truths.map(t => ` - ${t}`).join('\n') || ' - (none)'}
|
|
||||||
artifacts: []
|
|
||||||
key_links: []
|
|
||||||
---
|
|
||||||
|
|
||||||
<objective>
|
|
||||||
Plan ${wave} objective
|
|
||||||
</objective>
|
|
||||||
`;
|
|
||||||
|
|
||||||
function makePlanProject(files = {}) {
|
|
||||||
const dir = createTempProject();
|
|
||||||
fs.writeFileSync(path.join(dir, '.planning', 'ROADMAP.md'), '');
|
|
||||||
fs.mkdirSync(path.join(dir, '.planning', 'phases', '01-foundation'), { recursive: true });
|
|
||||||
for (const [rel, content] of Object.entries(files)) {
|
|
||||||
const abs = path.join(dir, rel);
|
|
||||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
||||||
fs.writeFileSync(abs, content, 'utf-8');
|
|
||||||
}
|
|
||||||
return dir;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('roadmap annotate-dependencies', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
afterEach(() => cleanup(tmpDir));
|
|
||||||
|
|
||||||
test('inserts wave headers for multi-wave plan set', () => {
|
|
||||||
tmpDir = makePlanProject({
|
|
||||||
'.planning/ROADMAP.md': `# Roadmap
|
|
||||||
|
|
||||||
### Phase 1: Foundation
|
|
||||||
**Goal:** Set up project
|
|
||||||
**Plans:** 2 plans
|
|
||||||
|
|
||||||
Plans:
|
|
||||||
- [ ] 01-01-PLAN.md — Set up DB
|
|
||||||
- [ ] 01-02-PLAN.md — Build API
|
|
||||||
`,
|
|
||||||
'.planning/phases/01-foundation/01-01-PLAN.md': PLAN_TEMPLATE(1, ['DB schema is correct']),
|
|
||||||
'.planning/phases/01-foundation/01-02-PLAN.md': PLAN_TEMPLATE(2, ['API returns 200']),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
||||||
|
|
||||||
const out = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(out.updated, true);
|
|
||||||
assert.strictEqual(out.waves, 2);
|
|
||||||
|
|
||||||
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
|
||||||
assert.ok(roadmap.includes('**Wave 1**'), 'Wave 1 header present');
|
|
||||||
assert.ok(roadmap.includes('**Wave 2**'), 'Wave 2 header present');
|
|
||||||
assert.ok(roadmap.includes('blocked on Wave 1'), 'Wave 2 blocked-on note present');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('does not insert wave headers for single-wave plan set', () => {
|
|
||||||
tmpDir = makePlanProject({
|
|
||||||
'.planning/ROADMAP.md': `# Roadmap
|
|
||||||
|
|
||||||
### Phase 1: Foundation
|
|
||||||
**Goal:** Set up project
|
|
||||||
**Plans:** 2 plans
|
|
||||||
|
|
||||||
Plans:
|
|
||||||
- [ ] 01-01-PLAN.md — Set up DB
|
|
||||||
- [ ] 01-02-PLAN.md — Build API
|
|
||||||
`,
|
|
||||||
'.planning/phases/01-foundation/01-01-PLAN.md': PLAN_TEMPLATE(1, ['DB schema is correct']),
|
|
||||||
'.planning/phases/01-foundation/01-02-PLAN.md': PLAN_TEMPLATE(1, ['API returns 200']),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
||||||
|
|
||||||
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
|
||||||
assert.ok(!roadmap.includes('**Wave 1**'), 'no Wave header for single-wave set');
|
|
||||||
assert.ok(!roadmap.includes('blocked on'), 'no blocked-on note for single wave');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('surfaces cross-cutting constraints when truths appear in 2+ plans', () => {
|
|
||||||
const sharedTruth = 'All endpoints require auth';
|
|
||||||
tmpDir = makePlanProject({
|
|
||||||
'.planning/ROADMAP.md': `# Roadmap
|
|
||||||
|
|
||||||
### Phase 1: Foundation
|
|
||||||
**Goal:** Set up project
|
|
||||||
**Plans:** 2 plans
|
|
||||||
|
|
||||||
Plans:
|
|
||||||
- [ ] 01-01-PLAN.md — Set up DB
|
|
||||||
- [ ] 01-02-PLAN.md — Build API
|
|
||||||
`,
|
|
||||||
'.planning/phases/01-foundation/01-01-PLAN.md': PLAN_TEMPLATE(1, [sharedTruth, 'DB schema is correct']),
|
|
||||||
'.planning/phases/01-foundation/01-02-PLAN.md': PLAN_TEMPLATE(2, [sharedTruth, 'API returns 200']),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
||||||
|
|
||||||
const out = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(out.cross_cutting_constraints, 1);
|
|
||||||
|
|
||||||
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
|
||||||
assert.ok(roadmap.includes('Cross-cutting constraints:'), 'constraints subsection present');
|
|
||||||
assert.ok(roadmap.includes(sharedTruth), 'shared truth listed');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('#1154: surfaces a cross-cutting backstop (object-form) truth by its statement, not dropped', () => {
|
|
||||||
// An object-form backstop truth `{ statement, verification: backstop }` (the #1154 non-inferable
|
|
||||||
// marker on must_haves.truths) shared across 2 plans must be coerced by its `statement` — the
|
|
||||||
// Hyrum backward-compat guard: a truth-reader must tolerate the new object form, never drop it.
|
|
||||||
const backstopTruth = 'statement: Adjacent touching intervals merge\n verification: backstop';
|
|
||||||
tmpDir = makePlanProject({
|
|
||||||
'.planning/ROADMAP.md': `# Roadmap
|
|
||||||
|
|
||||||
### Phase 1: Foundation
|
|
||||||
**Goal:** Set up project
|
|
||||||
**Plans:** 2 plans
|
|
||||||
|
|
||||||
Plans:
|
|
||||||
- [ ] 01-01-PLAN.md — Set up DB
|
|
||||||
- [ ] 01-02-PLAN.md — Build API
|
|
||||||
`,
|
|
||||||
'.planning/phases/01-foundation/01-01-PLAN.md': PLAN_TEMPLATE(1, [backstopTruth, 'DB schema is correct']),
|
|
||||||
'.planning/phases/01-foundation/01-02-PLAN.md': PLAN_TEMPLATE(2, [backstopTruth, 'API returns 200']),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
||||||
const out = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(out.cross_cutting_constraints, 1, 'the shared backstop truth is surfaced, not dropped');
|
|
||||||
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
|
||||||
assert.ok(roadmap.includes('Adjacent touching intervals merge'), 'surfaced by its statement text, not [object Object]');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('does not surface constraints that appear in only one plan', () => {
|
|
||||||
tmpDir = makePlanProject({
|
|
||||||
'.planning/ROADMAP.md': `# Roadmap
|
|
||||||
|
|
||||||
### Phase 1: Foundation
|
|
||||||
**Goal:** Set up project
|
|
||||||
**Plans:** 2 plans
|
|
||||||
|
|
||||||
Plans:
|
|
||||||
- [ ] 01-01-PLAN.md — Set up DB
|
|
||||||
- [ ] 01-02-PLAN.md — Build API
|
|
||||||
`,
|
|
||||||
'.planning/phases/01-foundation/01-01-PLAN.md': PLAN_TEMPLATE(1, ['Only in plan 1']),
|
|
||||||
'.planning/phases/01-foundation/01-02-PLAN.md': PLAN_TEMPLATE(2, ['Only in plan 2']),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
||||||
|
|
||||||
const out = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(out.cross_cutting_constraints, 0);
|
|
||||||
|
|
||||||
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
|
||||||
assert.ok(!roadmap.includes('Cross-cutting constraints:'), 'no constraints section when none are cross-cutting');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('is idempotent — running twice does not double-insert wave headers', () => {
|
|
||||||
tmpDir = makePlanProject({
|
|
||||||
'.planning/ROADMAP.md': `# Roadmap
|
|
||||||
|
|
||||||
### Phase 1: Foundation
|
|
||||||
**Goal:** Set up project
|
|
||||||
**Plans:** 2 plans
|
|
||||||
|
|
||||||
Plans:
|
|
||||||
- [ ] 01-01-PLAN.md — Set up DB
|
|
||||||
- [ ] 01-02-PLAN.md — Build API
|
|
||||||
`,
|
|
||||||
'.planning/phases/01-foundation/01-01-PLAN.md': PLAN_TEMPLATE(1),
|
|
||||||
'.planning/phases/01-foundation/01-02-PLAN.md': PLAN_TEMPLATE(2),
|
|
||||||
});
|
|
||||||
|
|
||||||
runGsdTools('roadmap annotate-dependencies 1', tmpDir);
|
|
||||||
const secondResult = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
|
|
||||||
assert.ok(secondResult.success);
|
|
||||||
|
|
||||||
const out = JSON.parse(secondResult.output);
|
|
||||||
assert.strictEqual(out.updated, false, 'second run should be no-op');
|
|
||||||
|
|
||||||
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
|
||||||
const waveMatches = roadmap.match(/\*\*Wave \d+\*\*/g) || [];
|
|
||||||
assert.strictEqual(waveMatches.length, 2, 'exactly 2 wave headers (not doubled)');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns no-op when phase has no plans', () => {
|
|
||||||
tmpDir = makePlanProject({
|
|
||||||
'.planning/ROADMAP.md': `# Roadmap\n\n### Phase 1: Foundation\n**Goal:** Set up project\n`,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
|
|
||||||
assert.ok(result.success);
|
|
||||||
const out = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(out.updated, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('#2757: truths containing colons do not crash annotate-dependencies', () => {
|
|
||||||
// Unquoted truths with colons (Rails idioms: db:seed, /foo/:id, Class::Method)
|
|
||||||
// caused parseMustHavesBlock to return {} instead of a string, then t.trim() threw.
|
|
||||||
const colonTruths = [
|
|
||||||
'GET /foo/:id resolves to controller#show',
|
|
||||||
'Class::Method is idempotent',
|
|
||||||
'"Quoted truth with colon: inside"',
|
|
||||||
];
|
|
||||||
tmpDir = makePlanProject({
|
|
||||||
'.planning/ROADMAP.md': `# Roadmap\n\n### Phase 1: Foundation\n**Goal:** Set up project\n**Plans:** 1 plan\n\nPlans:\n- [ ] 01-01-PLAN.md — Repro plan\n`,
|
|
||||||
'.planning/phases/01-foundation/01-01-PLAN.md': PLAN_TEMPLATE(1, colonTruths),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
|
|
||||||
assert.ok(result.success, `Command threw on colon-containing truths: ${result.error}`);
|
|
||||||
const out = JSON.parse(result.output);
|
|
||||||
assert.ok(typeof out.updated === 'boolean', 'should return a valid result object');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('#314 map-lookup: found-path uses plan wave, miss-path defaults to wave 1', () => {
|
|
||||||
// Behavior lock for the O(1) Map swap: asserts BOTH branches of the lookup.
|
|
||||||
// - 01-01-PLAN.md is in planData (wave 2) → checklist line must land under Wave 2.
|
|
||||||
// - 01-99-PLAN.md is NOT in planData → null-on-miss → defaults to wave 1.
|
|
||||||
tmpDir = makePlanProject({
|
|
||||||
'.planning/ROADMAP.md': `# Roadmap
|
|
||||||
|
|
||||||
### Phase 1: Foundation
|
|
||||||
**Goal:** Set up project
|
|
||||||
**Plans:** 2 plans
|
|
||||||
|
|
||||||
Plans:
|
|
||||||
- [ ] 01-01-PLAN.md — Known plan
|
|
||||||
- [ ] 01-99-PLAN.md — Unknown plan (no PLAN.md)
|
|
||||||
`,
|
|
||||||
'.planning/phases/01-foundation/01-01-PLAN.md': PLAN_TEMPLATE(2),
|
|
||||||
// 01-99-PLAN.md intentionally absent — simulates a checklist entry with no backing plan file
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
||||||
|
|
||||||
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
|
||||||
|
|
||||||
// Both waves must be present (wave 1 from the miss, wave 2 from the found entry)
|
|
||||||
assert.ok(roadmap.includes('**Wave 1**'), 'Wave 1 header present (miss-path default)');
|
|
||||||
assert.ok(roadmap.includes('**Wave 2**'), 'Wave 2 header present (found-path)');
|
|
||||||
|
|
||||||
// Known plan (wave 2) must appear AFTER Wave 2 header
|
|
||||||
const wave2Idx = roadmap.indexOf('**Wave 2**');
|
|
||||||
const knownLineIdx = roadmap.indexOf('01-01-PLAN.md');
|
|
||||||
assert.ok(knownLineIdx > wave2Idx, 'known plan line grouped under Wave 2');
|
|
||||||
|
|
||||||
// Unknown plan (wave 1 default) must appear AFTER Wave 1 header and BEFORE Wave 2 header
|
|
||||||
const wave1Idx = roadmap.indexOf('**Wave 1**');
|
|
||||||
const unknownLineIdx = roadmap.indexOf('01-99-PLAN.md');
|
|
||||||
assert.ok(unknownLineIdx > wave1Idx, 'unknown plan line grouped under Wave 1');
|
|
||||||
assert.ok(unknownLineIdx < wave2Idx, 'unknown plan line appears before Wave 2 section');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('plan-phase.md documents annotate-dependencies step', () => {
|
|
||||||
const planPhase = fs.readFileSync(
|
|
||||||
path.join(__dirname, '../gsd-core/workflows/plan-phase.md'), 'utf-8'
|
|
||||||
);
|
|
||||||
assert.ok(planPhase.includes('annotate-dependencies'), 'plan-phase.md references annotate-dependencies command');
|
|
||||||
assert.ok(planPhase.includes('13d'), 'plan-phase.md has step 13d');
|
|
||||||
assert.ok(planPhase.includes('Cross-cutting constraints'), 'plan-phase.md documents cross-cutting constraints');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
/**
|
|
||||||
* Regression tests for issue #3167: configurable /gsd-ship PR body sections.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// allow-test-rule: source-text-is-the-product
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
const { describe, test, afterEach } = require('node:test');
|
|
||||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
|
||||||
|
|
||||||
const repoRoot = path.resolve(__dirname, '..');
|
|
||||||
const tmpDirs = [];
|
|
||||||
|
|
||||||
function readRepoFile(relativePath) {
|
|
||||||
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeProject() {
|
|
||||||
const tmpDir = createTempProject('gsd-3167-');
|
|
||||||
tmpDirs.push(tmpDir);
|
|
||||||
return tmpDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
while (tmpDirs.length) {
|
|
||||||
cleanup(tmpDirs.pop());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('ship.pr_body_sections config (#3167)', () => {
|
|
||||||
test('CLI config-set accepts additional PR body section arrays', () => {
|
|
||||||
const cwd = makeProject();
|
|
||||||
const value = JSON.stringify([
|
|
||||||
{
|
|
||||||
heading: 'Risks & Rollback',
|
|
||||||
enabled: true,
|
|
||||||
source: 'PLAN.md ## Risks || PLAN.md ## Rollback',
|
|
||||||
fallback: '- Rollback: revert this PR.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
heading: 'Stakeholder Sign-off',
|
|
||||||
enabled: false,
|
|
||||||
template: '- Product owner: pending',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const result = runGsdTools(['config-set', 'ship.pr_body_sections', value, '--raw'], cwd, { HOME: cwd });
|
|
||||||
|
|
||||||
assert.equal(result.success, true, result.error);
|
|
||||||
const config = JSON.parse(fs.readFileSync(path.join(cwd, '.planning', 'config.json'), 'utf8'));
|
|
||||||
assert.deepEqual(config.ship.pr_body_sections, [
|
|
||||||
{
|
|
||||||
heading: 'Risks & Rollback',
|
|
||||||
enabled: true,
|
|
||||||
source: 'PLAN.md ## Risks || PLAN.md ## Rollback',
|
|
||||||
fallback: '- Rollback: revert this PR.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
heading: 'Stakeholder Sign-off',
|
|
||||||
enabled: false,
|
|
||||||
template: '- Product owner: pending',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('CLI config-set rejects malformed PR body section values before writing config', () => {
|
|
||||||
const cwd = makeProject();
|
|
||||||
|
|
||||||
const notArray = runGsdTools(
|
|
||||||
['config-set', 'ship.pr_body_sections', JSON.stringify({ heading: 'Not an array' }), '--raw'],
|
|
||||||
cwd,
|
|
||||||
{ HOME: cwd }
|
|
||||||
);
|
|
||||||
assert.equal(notArray.success, false);
|
|
||||||
assert.match(notArray.error, /ship\.pr_body_sections.*JSON array/);
|
|
||||||
|
|
||||||
const missingHeading = runGsdTools(
|
|
||||||
['config-set', 'ship.pr_body_sections', JSON.stringify([{ fallback: '- Missing heading' }]), '--raw'],
|
|
||||||
cwd,
|
|
||||||
{ HOME: cwd }
|
|
||||||
);
|
|
||||||
assert.equal(missingHeading.success, false);
|
|
||||||
assert.match(missingHeading.error, /heading/);
|
|
||||||
|
|
||||||
const invalidEnabled = runGsdTools(
|
|
||||||
['config-set', 'ship.pr_body_sections', JSON.stringify([{ heading: 'Toggle', enabled: 'yes', fallback: '- item' }]), '--raw'],
|
|
||||||
cwd,
|
|
||||||
{ HOME: cwd }
|
|
||||||
);
|
|
||||||
assert.equal(invalidEnabled.success, false);
|
|
||||||
assert.match(invalidEnabled.error, /enabled/);
|
|
||||||
|
|
||||||
assert.equal(fs.existsSync(path.join(cwd, '.planning', 'config.json')), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('CLI config-new-project validates onboarded PR body sections before writing config', () => {
|
|
||||||
const cwd = makeProject();
|
|
||||||
const choices = JSON.stringify({
|
|
||||||
ship: {
|
|
||||||
pr_body_sections: [
|
|
||||||
{
|
|
||||||
heading: 'Invalid source',
|
|
||||||
source: 'package.json ## Scripts',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = runGsdTools(['config-new-project', choices], cwd, { HOME: cwd });
|
|
||||||
|
|
||||||
assert.equal(result.success, false);
|
|
||||||
assert.match(result.error, /source must use selectors/);
|
|
||||||
assert.equal(fs.existsSync(path.join(cwd, '.planning', 'config.json')), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ship workflow composes configured sections as append-only extensions', () => {
|
|
||||||
const workflow = readRepoFile('gsd-core/workflows/ship.md');
|
|
||||||
|
|
||||||
assert.match(workflow, /config-get ship\.pr_body_sections --default '\[\]'/);
|
|
||||||
assert.match(workflow, /append-only/i);
|
|
||||||
assert.match(workflow, /enabled.*false/i);
|
|
||||||
assert.match(workflow, /cannot replace/i);
|
|
||||||
assert.match(workflow, /Summary[\s\S]*Changes[\s\S]*Requirements Addressed[\s\S]*Verification[\s\S]*Key Decisions/);
|
|
||||||
assert.match(workflow, /\{phase_number\}[\s\S]*\{phase_name\}[\s\S]*\{phase_dir\}[\s\S]*\{base_branch\}[\s\S]*\{padded_phase\}/);
|
|
||||||
assert.match(workflow, /User Stories & Acceptance Criteria/);
|
|
||||||
assert.match(workflow, /Definition of Done/);
|
|
||||||
assert.match(workflow, /--body-file/);
|
|
||||||
assert.match(workflow, /trap 'rm -f "\$\{PR_BODY_FILE:-\}"' EXIT/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('default config and documentation describe ship.pr_body_sections', () => {
|
|
||||||
const template = JSON.parse(readRepoFile('gsd-core/templates/config.json'));
|
|
||||||
assert.deepEqual(template.ship.pr_body_sections, []);
|
|
||||||
|
|
||||||
const docs = readRepoFile('docs/CONFIGURATION.md');
|
|
||||||
assert.match(docs, /`ship\.pr_body_sections`/);
|
|
||||||
assert.match(docs, /additional PR body sections/i);
|
|
||||||
assert.match(docs, /append-only/i);
|
|
||||||
assert.match(docs, /lean\/agile PRD/i);
|
|
||||||
assert.match(docs, /Definition of Done/);
|
|
||||||
|
|
||||||
const planningConfig = readRepoFile('gsd-core/references/planning-config.md');
|
|
||||||
assert.match(planningConfig, /ship\.pr_body_sections/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('new-project onboarding can seed enabled or disabled PR body sections', () => {
|
|
||||||
const workflow = readRepoFile('gsd-core/workflows/new-project.md');
|
|
||||||
|
|
||||||
assert.match(workflow, /ship\.pr_body_sections/);
|
|
||||||
assert.match(workflow, /enabled.*true/);
|
|
||||||
assert.match(workflow, /enabled.*false/);
|
|
||||||
assert.match(workflow, /User Stories & Acceptance Criteria/);
|
|
||||||
assert.match(workflow, /Risks & Dependencies/);
|
|
||||||
assert.match(workflow, /Success Metrics & Release Criteria/);
|
|
||||||
assert.match(workflow, /Stakeholder Review & Approval/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enum validation for code_quality.fallow.scope and code_quality.fallow.profile.
|
|
||||||
*
|
|
||||||
* Fixes H5 from #3424 review: config-set silently accepted invalid enum values
|
|
||||||
* (e.g. scope=fullrepo) and fell through to default behavior. This test asserts
|
|
||||||
* that invalid values are rejected with a helpful error, and valid values pass.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { describe, test } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
|
|
||||||
|
|
||||||
describe('feat-3210 / H5: enum validation for code_quality.fallow.scope and .profile', () => {
|
|
||||||
// --- code_quality.fallow.scope ---
|
|
||||||
|
|
||||||
test('config-set code_quality.fallow.scope=fullrepo is REJECTED with helpful error', (t) => {
|
|
||||||
const tmpDir = createTempProject();
|
|
||||||
t.after(() => cleanup(tmpDir));
|
|
||||||
|
|
||||||
const result = runGsdTools(
|
|
||||||
['config-set', 'code_quality.fallow.scope', 'fullrepo'],
|
|
||||||
tmpDir
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
!result.success,
|
|
||||||
'config-set code_quality.fallow.scope=fullrepo must fail, but it succeeded'
|
|
||||||
);
|
|
||||||
const combined = (result.output || '') + (result.error || '');
|
|
||||||
assert.ok(
|
|
||||||
combined.includes('phase') && combined.includes('repo'),
|
|
||||||
`Error message must mention valid values "phase" and "repo", got: ${combined}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('config-set code_quality.fallow.scope=phase is ACCEPTED', (t) => {
|
|
||||||
const tmpDir = createTempProject();
|
|
||||||
t.after(() => cleanup(tmpDir));
|
|
||||||
|
|
||||||
const result = runGsdTools(
|
|
||||||
['config-set', 'code_quality.fallow.scope', 'phase'],
|
|
||||||
tmpDir
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
result.success,
|
|
||||||
[
|
|
||||||
'config-set code_quality.fallow.scope=phase must succeed,',
|
|
||||||
'stdout: ' + result.output,
|
|
||||||
'stderr: ' + result.error,
|
|
||||||
].join('\n')
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('config-set code_quality.fallow.scope=repo is ACCEPTED', (t) => {
|
|
||||||
const tmpDir = createTempProject();
|
|
||||||
t.after(() => cleanup(tmpDir));
|
|
||||||
|
|
||||||
const result = runGsdTools(
|
|
||||||
['config-set', 'code_quality.fallow.scope', 'repo'],
|
|
||||||
tmpDir
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
result.success,
|
|
||||||
[
|
|
||||||
'config-set code_quality.fallow.scope=repo must succeed,',
|
|
||||||
'stdout: ' + result.output,
|
|
||||||
'stderr: ' + result.error,
|
|
||||||
].join('\n')
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('config-set code_quality.fallow.scope=PHASE (wrong case) is REJECTED', (t) => {
|
|
||||||
const tmpDir = createTempProject();
|
|
||||||
t.after(() => cleanup(tmpDir));
|
|
||||||
|
|
||||||
const result = runGsdTools(
|
|
||||||
['config-set', 'code_quality.fallow.scope', 'PHASE'],
|
|
||||||
tmpDir
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
!result.success,
|
|
||||||
'config-set code_quality.fallow.scope=PHASE must fail (values are case-sensitive)'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- code_quality.fallow.profile ---
|
|
||||||
|
|
||||||
test('config-set code_quality.fallow.profile=aggressive is REJECTED with helpful error', (t) => {
|
|
||||||
const tmpDir = createTempProject();
|
|
||||||
t.after(() => cleanup(tmpDir));
|
|
||||||
|
|
||||||
const result = runGsdTools(
|
|
||||||
['config-set', 'code_quality.fallow.profile', 'aggressive'],
|
|
||||||
tmpDir
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
!result.success,
|
|
||||||
'config-set code_quality.fallow.profile=aggressive must fail, but it succeeded'
|
|
||||||
);
|
|
||||||
const combined = (result.output || '') + (result.error || '');
|
|
||||||
assert.ok(
|
|
||||||
combined.includes('minimal') && combined.includes('standard') && combined.includes('strict'),
|
|
||||||
`Error message must mention valid values "minimal", "standard", "strict", got: ${combined}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('config-set code_quality.fallow.profile=minimal is ACCEPTED', (t) => {
|
|
||||||
const tmpDir = createTempProject();
|
|
||||||
t.after(() => cleanup(tmpDir));
|
|
||||||
|
|
||||||
const result = runGsdTools(
|
|
||||||
['config-set', 'code_quality.fallow.profile', 'minimal'],
|
|
||||||
tmpDir
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
result.success,
|
|
||||||
[
|
|
||||||
'config-set code_quality.fallow.profile=minimal must succeed,',
|
|
||||||
'stdout: ' + result.output,
|
|
||||||
'stderr: ' + result.error,
|
|
||||||
].join('\n')
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('config-set code_quality.fallow.profile=standard is ACCEPTED', (t) => {
|
|
||||||
const tmpDir = createTempProject();
|
|
||||||
t.after(() => cleanup(tmpDir));
|
|
||||||
|
|
||||||
const result = runGsdTools(
|
|
||||||
['config-set', 'code_quality.fallow.profile', 'standard'],
|
|
||||||
tmpDir
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
result.success,
|
|
||||||
[
|
|
||||||
'config-set code_quality.fallow.profile=standard must succeed,',
|
|
||||||
'stdout: ' + result.output,
|
|
||||||
'stderr: ' + result.error,
|
|
||||||
].join('\n')
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('config-set code_quality.fallow.profile=strict is ACCEPTED', (t) => {
|
|
||||||
const tmpDir = createTempProject();
|
|
||||||
t.after(() => cleanup(tmpDir));
|
|
||||||
|
|
||||||
const result = runGsdTools(
|
|
||||||
['config-set', 'code_quality.fallow.profile', 'strict'],
|
|
||||||
tmpDir
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
result.success,
|
|
||||||
[
|
|
||||||
'config-set code_quality.fallow.profile=strict must succeed,',
|
|
||||||
'stdout: ' + result.output,
|
|
||||||
'stderr: ' + result.error,
|
|
||||||
].join('\n')
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('config-set code_quality.fallow.profile=unknown is REJECTED', (t) => {
|
|
||||||
const tmpDir = createTempProject();
|
|
||||||
t.after(() => cleanup(tmpDir));
|
|
||||||
|
|
||||||
const result = runGsdTools(
|
|
||||||
['config-set', 'code_quality.fallow.profile', 'unknown'],
|
|
||||||
tmpDir
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
!result.success,
|
|
||||||
'config-set code_quality.fallow.profile=unknown must fail'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
/**
|
|
||||||
* Tests for the --json-errors mode added in #3255.
|
|
||||||
*
|
|
||||||
* When gsd-tools is invoked with --json-errors, all error() calls emit a
|
|
||||||
* structured JSON object to stderr:
|
|
||||||
*
|
|
||||||
* { ok: false, reason: "<error_code>", message: "<human text>" }
|
|
||||||
*
|
|
||||||
* This lets tests assert on typed reason codes instead of grepping free-form
|
|
||||||
* stderr text. All assertions below parse the captured stderr via JSON.parse
|
|
||||||
* and inspect typed fields — never result.error.includes() (#2974 / k001).
|
|
||||||
*
|
|
||||||
* Covered error paths (representative set, each exercises a different branch):
|
|
||||||
* 1. Unknown top-level command → reason: "sdk_unknown_command"
|
|
||||||
* 2. Unknown dotted command → reason: "sdk_unknown_command"
|
|
||||||
* 3. Missing required argument → reason: "usage" (--pick without value)
|
|
||||||
* 4. Config key not found → reason: "config_key_not_found"
|
|
||||||
* 5. Unknown subcommand → reason: "sdk_unknown_command"
|
|
||||||
* 6. GSD_JSON_ERRORS=1 env var → same structured output without --flag
|
|
||||||
* 7. Successful command unaffected
|
|
||||||
* 8. Error object shape is stable ({ok, reason, message})
|
|
||||||
* 9. Single error line per invocation
|
|
||||||
* 10. Unknown flag → reason: "usage"
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
|
||||||
|
|
||||||
// Helper: run gsd-tools with --json-errors and parse the structured stderr.
|
|
||||||
// Returns the parsed object, or throws if stderr is not valid JSON.
|
|
||||||
function runJsonErrors(args, tmpDir, env = {}) {
|
|
||||||
const allArgs = ['--json-errors', ...args];
|
|
||||||
const result = runGsdTools(allArgs, tmpDir, env);
|
|
||||||
// Must have failed
|
|
||||||
assert.strictEqual(result.success, false,
|
|
||||||
`Expected failure with --json-errors for args: ${args.join(' ')}\nstdout: ${result.output}\nstderr: ${result.error}`);
|
|
||||||
let parsed;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(result.error);
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(
|
|
||||||
`--json-errors must emit valid JSON on stderr.\n` +
|
|
||||||
`Args: ${args.join(' ')}\n` +
|
|
||||||
`stderr: ${result.error}\n` +
|
|
||||||
`parse error: ${e.message}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('feat #3255: --json-errors mode emits structured error objects', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = createTempProject();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 1. Unknown top-level command ─────────────────────────────────────────
|
|
||||||
|
|
||||||
test('unknown top-level command emits { ok: false, reason: "sdk_unknown_command" }', () => {
|
|
||||||
const parsed = runJsonErrors(['totally-unknown-command-xyzzy'], tmpDir);
|
|
||||||
|
|
||||||
assert.strictEqual(parsed.ok, false,
|
|
||||||
'error object must have ok: false');
|
|
||||||
assert.strictEqual(parsed.reason, 'sdk_unknown_command',
|
|
||||||
`reason must be "sdk_unknown_command", got: ${parsed.reason}`);
|
|
||||||
assert.ok(typeof parsed.message === 'string' && parsed.message.length > 0,
|
|
||||||
'message must be a non-empty string');
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 2. Unknown dotted command ────────────────────────────────────────────
|
|
||||||
|
|
||||||
test('unknown dotted command (foo.bar) emits { ok: false, reason: "sdk_unknown_command" }', () => {
|
|
||||||
const parsed = runJsonErrors(['foo.bar'], tmpDir);
|
|
||||||
|
|
||||||
assert.strictEqual(parsed.ok, false,
|
|
||||||
'error object must have ok: false');
|
|
||||||
assert.strictEqual(parsed.reason, 'sdk_unknown_command',
|
|
||||||
`dotted unknown command reason must be "sdk_unknown_command", got: ${parsed.reason}`);
|
|
||||||
assert.ok(typeof parsed.message === 'string' && parsed.message.length > 0,
|
|
||||||
'message must be a non-empty string');
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 3. Missing --pick value ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
test('--pick without value emits { ok: false, reason: "usage" }', () => {
|
|
||||||
const parsed = runJsonErrors(['generate-slug', 'test-text', '--pick'], tmpDir);
|
|
||||||
|
|
||||||
assert.strictEqual(parsed.ok, false,
|
|
||||||
'error object must have ok: false');
|
|
||||||
assert.strictEqual(parsed.reason, 'usage',
|
|
||||||
`missing --pick value reason must be "usage", got: ${parsed.reason}`);
|
|
||||||
assert.ok(typeof parsed.message === 'string' && parsed.message.length > 0,
|
|
||||||
'message must be a non-empty string');
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 4. Config key not found ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
test('config-get for absent key emits { ok: false, reason: "config_key_not_found" }', () => {
|
|
||||||
// Initialise config.json first so we reach the "key not found" branch
|
|
||||||
// rather than the "no config.json" branch.
|
|
||||||
runGsdTools(['config-ensure-section'], tmpDir);
|
|
||||||
|
|
||||||
const parsed = runJsonErrors(['config-get', 'nonexistent_config_key_xyzzy'], tmpDir);
|
|
||||||
|
|
||||||
assert.strictEqual(parsed.ok, false,
|
|
||||||
'error object must have ok: false');
|
|
||||||
assert.strictEqual(parsed.reason, 'config_key_not_found',
|
|
||||||
`reason must be "config_key_not_found", got: ${parsed.reason}`);
|
|
||||||
assert.ok(typeof parsed.message === 'string' && parsed.message.length > 0,
|
|
||||||
'message must be a non-empty string');
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 5. Unknown subcommand within a domain ────────────────────────────────
|
|
||||||
|
|
||||||
test('unknown intel subcommand emits { ok: false, reason: "sdk_unknown_command" }', () => {
|
|
||||||
const parsed = runJsonErrors(['intel', 'bogus-subcommand-xyzzy'], tmpDir);
|
|
||||||
|
|
||||||
assert.strictEqual(parsed.ok, false,
|
|
||||||
'error object must have ok: false');
|
|
||||||
assert.strictEqual(parsed.reason, 'sdk_unknown_command',
|
|
||||||
`unknown subcommand reason must be "sdk_unknown_command", got: ${parsed.reason}`);
|
|
||||||
assert.ok(typeof parsed.message === 'string' && parsed.message.length > 0,
|
|
||||||
'message must be a non-empty string');
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 6. GSD_JSON_ERRORS=1 env var activates structured mode ───────────────
|
|
||||||
|
|
||||||
test('GSD_JSON_ERRORS=1 env var produces same structured error as --json-errors flag', () => {
|
|
||||||
// Run with env var instead of --json-errors flag
|
|
||||||
const result = runGsdTools(
|
|
||||||
['totally-unknown-command-xyzzy'],
|
|
||||||
tmpDir,
|
|
||||||
{ GSD_JSON_ERRORS: '1' }
|
|
||||||
);
|
|
||||||
assert.strictEqual(result.success, false,
|
|
||||||
'command must fail');
|
|
||||||
let parsed;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(result.error);
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(
|
|
||||||
`GSD_JSON_ERRORS=1 must emit valid JSON on stderr.\n` +
|
|
||||||
`stderr: ${result.error}\n` +
|
|
||||||
`parse error: ${e.message}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
assert.strictEqual(parsed.ok, false,
|
|
||||||
'error object must have ok: false');
|
|
||||||
assert.strictEqual(parsed.reason, 'sdk_unknown_command',
|
|
||||||
`reason must be "sdk_unknown_command", got: ${parsed.reason}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 7. Successful commands are unaffected by --json-errors ───────────────
|
|
||||||
|
|
||||||
test('successful command with --json-errors flag still succeeds normally', () => {
|
|
||||||
const result = runGsdTools(
|
|
||||||
['--json-errors', 'generate-slug', 'hello-world'],
|
|
||||||
tmpDir
|
|
||||||
);
|
|
||||||
assert.strictEqual(result.success, true,
|
|
||||||
`Successful command must not be broken by --json-errors flag.\nstderr: ${result.error}`);
|
|
||||||
assert.ok(result.output.length > 0,
|
|
||||||
'stdout must be non-empty for successful generate-slug');
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 8. Error object shape is stable (no extra top-level keys) ────────────
|
|
||||||
|
|
||||||
test('error object contains exactly {ok, reason, message} — no extra keys', () => {
|
|
||||||
const parsed = runJsonErrors(['totally-unknown-command-xyzzy'], tmpDir);
|
|
||||||
|
|
||||||
const keys = Object.keys(parsed).sort();
|
|
||||||
assert.deepStrictEqual(keys, ['message', 'ok', 'reason'],
|
|
||||||
`error object must have exactly {ok, reason, message}. Got keys: ${keys.join(', ')}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 9. Multiple errors in one session: only the first error is emitted ───
|
|
||||||
|
|
||||||
test('only one error JSON line is emitted per invocation (process exits on first error)', () => {
|
|
||||||
const result = runGsdTools(
|
|
||||||
['--json-errors', 'totally-unknown-command-xyzzy'],
|
|
||||||
tmpDir
|
|
||||||
);
|
|
||||||
assert.strictEqual(result.success, false, 'must fail');
|
|
||||||
const lines = result.error.trim().split('\n').filter(l => l.length > 0);
|
|
||||||
assert.strictEqual(lines.length, 1,
|
|
||||||
`stderr must contain exactly one JSON line, got ${lines.length}:\n${result.error}`);
|
|
||||||
// Also verify the single line is valid JSON
|
|
||||||
const parsed = JSON.parse(lines[0]);
|
|
||||||
assert.strictEqual(parsed.ok, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 10. Unknown flag emits { ok: false, reason: "usage" } ────────────────
|
|
||||||
|
|
||||||
test('unknown version flag emits { ok: false, reason: "usage" }', () => {
|
|
||||||
const parsed = runJsonErrors(['--version', 'generate-slug', 'x'], tmpDir);
|
|
||||||
|
|
||||||
assert.strictEqual(parsed.ok, false, 'error object must have ok: false');
|
|
||||||
assert.strictEqual(parsed.reason, 'usage',
|
|
||||||
`--version flag reason must be "usage", got: ${parsed.reason}`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
/**
|
|
||||||
* Follow-up tests for #3310: every remaining `error()` call at a subcommand
|
|
||||||
* boundary or usage check in `gsd-tools.cjs` carries a typed `ERROR_REASON`.
|
|
||||||
*
|
|
||||||
* #3304 wired four representative paths (unknown top-level command, unknown
|
|
||||||
* intel subcommand, missing --pick value, --version flag). The rest fell
|
|
||||||
* through to `ERROR_REASON.UNKNOWN`. This file locks the post-#3310 contract:
|
|
||||||
*
|
|
||||||
* - Every "Unknown <subsystem> subcommand" emits reason: "sdk_unknown_command".
|
|
||||||
* - Every "Usage: ..." / missing-required-arg path emits reason: "usage".
|
|
||||||
*
|
|
||||||
* All assertions parse stderr via JSON.parse — never `.includes()` — per the
|
|
||||||
* #2974 / CONTRIBUTING.md "Prohibited: Raw Text Matching" rule.
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
|
||||||
|
|
||||||
// Run gsd-tools with GSD_JSON_ERRORS=1 (env-var activation, exercises the
|
|
||||||
// path #3304 added alongside the --json-errors flag) and parse the
|
|
||||||
// structured stderr. Returns the parsed object; throws if stderr is not JSON.
|
|
||||||
function runJsonErrors(args, tmpDir, env = {}) {
|
|
||||||
const result = runGsdTools(args, tmpDir, { ...env, GSD_JSON_ERRORS: '1' });
|
|
||||||
assert.strictEqual(result.success, false,
|
|
||||||
`Expected failure with GSD_JSON_ERRORS=1 for args: ${args.join(' ')}\n` +
|
|
||||||
`stdout: ${result.output}\nstderr: ${result.error}`);
|
|
||||||
let parsed;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(result.error);
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(
|
|
||||||
`GSD_JSON_ERRORS=1 must emit valid JSON on stderr.\n` +
|
|
||||||
`Args: ${args.join(' ')}\nstderr: ${result.error}\nparse error: ${e.message}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assert the typed-IR contract: object shape + reason. Keeps the per-test
|
|
||||||
// boilerplate minimal so each error-path test reads as a single fact.
|
|
||||||
function assertTypedError(parsed, expectedReason, label) {
|
|
||||||
assert.strictEqual(parsed.ok, false,
|
|
||||||
`${label}: error object must have ok: false`);
|
|
||||||
assert.strictEqual(parsed.reason, expectedReason,
|
|
||||||
`${label}: reason must be "${expectedReason}", got: ${parsed.reason}`);
|
|
||||||
assert.ok(typeof parsed.message === 'string' && parsed.message.length > 0,
|
|
||||||
`${label}: message must be a non-empty string`);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('feat #3310: typed ERROR_REASON codes on remaining error paths', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = createTempProject();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Unknown <subsystem> subcommand → SDK_UNKNOWN_COMMAND ────────────────
|
|
||||||
// Each of these used to fall through to reason: "unknown" before #3310.
|
|
||||||
|
|
||||||
test('unknown template subcommand → sdk_unknown_command', () => {
|
|
||||||
const parsed = runJsonErrors(['template', 'bogus-subcommand-xyzzy'], tmpDir);
|
|
||||||
assertTypedError(parsed, 'sdk_unknown_command', 'template');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('unknown frontmatter subcommand → sdk_unknown_command', () => {
|
|
||||||
// frontmatter expects subcommand at args[1] and file at args[2]; pass a
|
|
||||||
// bogus subcommand with a placeholder file so we definitely reach the
|
|
||||||
// unknown-subcommand branch, not an earlier validation.
|
|
||||||
const parsed = runJsonErrors(
|
|
||||||
['frontmatter', 'bogus-subcommand-xyzzy', 'placeholder.md'],
|
|
||||||
tmpDir
|
|
||||||
);
|
|
||||||
assertTypedError(parsed, 'sdk_unknown_command', 'frontmatter');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('unknown requirements subcommand → sdk_unknown_command', () => {
|
|
||||||
const parsed = runJsonErrors(['requirements', 'bogus-subcommand-xyzzy'], tmpDir);
|
|
||||||
assertTypedError(parsed, 'sdk_unknown_command', 'requirements');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('unknown milestone subcommand → sdk_unknown_command', () => {
|
|
||||||
const parsed = runJsonErrors(['milestone', 'bogus-subcommand-xyzzy'], tmpDir);
|
|
||||||
assertTypedError(parsed, 'sdk_unknown_command', 'milestone');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('unknown uat subcommand → sdk_unknown_command', () => {
|
|
||||||
const parsed = runJsonErrors(['uat', 'bogus-subcommand-xyzzy'], tmpDir);
|
|
||||||
assertTypedError(parsed, 'sdk_unknown_command', 'uat');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('unknown todo subcommand → sdk_unknown_command', () => {
|
|
||||||
const parsed = runJsonErrors(['todo', 'bogus-subcommand-xyzzy'], tmpDir);
|
|
||||||
assertTypedError(parsed, 'sdk_unknown_command', 'todo');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('unknown workstream subcommand → sdk_unknown_command', () => {
|
|
||||||
const parsed = runJsonErrors(['workstream', 'bogus-subcommand-xyzzy'], tmpDir);
|
|
||||||
assertTypedError(parsed, 'sdk_unknown_command', 'workstream');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('unknown graphify subcommand → sdk_unknown_command', () => {
|
|
||||||
const parsed = runJsonErrors(['graphify', 'bogus-subcommand-xyzzy'], tmpDir);
|
|
||||||
assertTypedError(parsed, 'sdk_unknown_command', 'graphify');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('unknown learnings subcommand → sdk_unknown_command', () => {
|
|
||||||
const parsed = runJsonErrors(['learnings', 'bogus-subcommand-xyzzy'], tmpDir);
|
|
||||||
assertTypedError(parsed, 'sdk_unknown_command', 'learnings');
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Missing required positional/flag values → USAGE ─────────────────────
|
|
||||||
// These previously emitted reason: "unknown" because the second argument
|
|
||||||
// to error() was absent.
|
|
||||||
|
|
||||||
test('missing --cwd value → usage', () => {
|
|
||||||
// The --cwd flag is consumed before the command dispatcher; passing it
|
|
||||||
// bare with no following value triggers the usage error at L253/L258.
|
|
||||||
const parsed = runJsonErrors(['--cwd'], tmpDir);
|
|
||||||
assertTypedError(parsed, 'usage', '--cwd missing value');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('invalid --cwd directory → usage', () => {
|
|
||||||
// --cwd <nonexistent-path> hits the existsSync / isDirectory check at L264.
|
|
||||||
const parsed = runJsonErrors(
|
|
||||||
['--cwd', '/this/path/should/not/exist/anywhere/xyzzy', 'state', 'load'],
|
|
||||||
tmpDir
|
|
||||||
);
|
|
||||||
assertTypedError(parsed, 'usage', 'invalid --cwd directory');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('intel query missing term → usage', () => {
|
|
||||||
const parsed = runJsonErrors(['intel', 'query'], tmpDir);
|
|
||||||
assertTypedError(parsed, 'usage', 'intel query missing term');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('intel patch-meta missing file path → usage', () => {
|
|
||||||
const parsed = runJsonErrors(['intel', 'patch-meta'], tmpDir);
|
|
||||||
assertTypedError(parsed, 'usage', 'intel patch-meta missing file');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('intel extract-exports missing file path → usage', () => {
|
|
||||||
const parsed = runJsonErrors(['intel', 'extract-exports'], tmpDir);
|
|
||||||
assertTypedError(parsed, 'usage', 'intel extract-exports missing file');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('graphify query missing term → usage', () => {
|
|
||||||
const parsed = runJsonErrors(['graphify', 'query'], tmpDir);
|
|
||||||
assertTypedError(parsed, 'usage', 'graphify query missing term');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('learnings query missing --tag → usage', () => {
|
|
||||||
const parsed = runJsonErrors(['learnings', 'query'], tmpDir);
|
|
||||||
assertTypedError(parsed, 'usage', 'learnings query missing --tag');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('learnings prune missing --older-than → usage', () => {
|
|
||||||
const parsed = runJsonErrors(['learnings', 'prune'], tmpDir);
|
|
||||||
assertTypedError(parsed, 'usage', 'learnings prune missing --older-than');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('learnings delete missing id → usage', () => {
|
|
||||||
const parsed = runJsonErrors(['learnings', 'delete'], tmpDir);
|
|
||||||
assertTypedError(parsed, 'usage', 'learnings delete missing id');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('extract-messages missing project arg → usage', () => {
|
|
||||||
// L877 — args[1] is undefined or starts with '--'.
|
|
||||||
const parsed = runJsonErrors(['extract-messages'], tmpDir);
|
|
||||||
assertTypedError(parsed, 'usage', 'extract-messages missing project');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('write-profile missing --input → usage', () => {
|
|
||||||
const parsed = runJsonErrors(['write-profile'], tmpDir);
|
|
||||||
assertTypedError(parsed, 'usage', 'write-profile missing --input');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('detect-custom-files missing --config-dir → usage', () => {
|
|
||||||
const parsed = runJsonErrors(['detect-custom-files'], tmpDir);
|
|
||||||
assertTypedError(parsed, 'usage', 'detect-custom-files missing --config-dir');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('detect-custom-files invalid --config-dir → usage', () => {
|
|
||||||
const parsed = runJsonErrors(
|
|
||||||
['detect-custom-files', '--config-dir', '/nonexistent/path/xyzzy'],
|
|
||||||
tmpDir
|
|
||||||
);
|
|
||||||
assertTypedError(parsed, 'usage', 'detect-custom-files invalid --config-dir');
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Shape regression guard: every newly-typed path emits the canonical
|
|
||||||
// {ok, reason, message} object — no leakage of reason: "unknown". ────
|
|
||||||
|
|
||||||
test('every remaining typed path emits the canonical {ok, reason, message} shape', () => {
|
|
||||||
const probes = [
|
|
||||||
['template', 'bogus'],
|
|
||||||
['frontmatter', 'bogus', 'placeholder.md'],
|
|
||||||
['requirements', 'bogus'],
|
|
||||||
['milestone', 'bogus'],
|
|
||||||
['uat', 'bogus'],
|
|
||||||
['todo', 'bogus'],
|
|
||||||
['workstream', 'bogus'],
|
|
||||||
['graphify', 'bogus'],
|
|
||||||
['learnings', 'bogus'],
|
|
||||||
['intel', 'query'],
|
|
||||||
['extract-messages'],
|
|
||||||
['write-profile'],
|
|
||||||
['detect-custom-files'],
|
|
||||||
];
|
|
||||||
for (const args of probes) {
|
|
||||||
const parsed = runJsonErrors(args, tmpDir);
|
|
||||||
const keys = Object.keys(parsed).sort();
|
|
||||||
assert.deepStrictEqual(keys, ['message', 'ok', 'reason'],
|
|
||||||
`args ${args.join(' ')}: keys must be exactly {ok,reason,message}, got ${keys.join(',')}`);
|
|
||||||
assert.notStrictEqual(parsed.reason, 'unknown',
|
|
||||||
`args ${args.join(' ')}: reason must be a typed code, not the fallback "unknown"`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Regression tests for `gsd-tools query phase.list-plans <N>` (#1437).
|
|
||||||
*
|
|
||||||
* Prior to this fix, `phase.list-plans` was not registered in the
|
|
||||||
* phase-command-router, so any invocation produced:
|
|
||||||
* "Error: Unknown phase subcommand. Available: uat-passed, next-decimal, ..."
|
|
||||||
*
|
|
||||||
* These tests exercise the full dispatch path:
|
|
||||||
* gsd-tools → phase-command-router → phase.cmdPhaseListPlans
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
|
||||||
|
|
||||||
// ─── Fixture helpers ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function setupProject(phaseSlug = '01-feature') {
|
|
||||||
const tmpDir = createTempProject();
|
|
||||||
// Minimal ROADMAP so findPhaseInternal can resolve the phase directory
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
||||||
[
|
|
||||||
'# Roadmap',
|
|
||||||
'',
|
|
||||||
'- [ ] Phase 1: Feature',
|
|
||||||
'',
|
|
||||||
'### Phase 1: Feature',
|
|
||||||
'**Goal:** Build feature',
|
|
||||||
'**Plans:** 1 plans',
|
|
||||||
'',
|
|
||||||
].join('\n'),
|
|
||||||
);
|
|
||||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', phaseSlug);
|
|
||||||
fs.mkdirSync(phaseDir, { recursive: true });
|
|
||||||
return { tmpDir, phaseDir };
|
|
||||||
}
|
|
||||||
|
|
||||||
function touch(dir, ...files) {
|
|
||||||
for (const f of files) {
|
|
||||||
fs.writeFileSync(path.join(dir, f), '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
let tmpDir;
|
|
||||||
let phaseDir;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
const proj = setupProject('01-feature');
|
|
||||||
tmpDir = proj.tmpDir;
|
|
||||||
phaseDir = proj.phaseDir;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('bug-1437 — phase.list-plans is wired in gsd-tools', () => {
|
|
||||||
test('command no longer returns Unknown phase subcommand error', () => {
|
|
||||||
touch(phaseDir, '01-01-PLAN.md');
|
|
||||||
const result = runGsdTools(['query', 'phase.list-plans', '1'], tmpDir);
|
|
||||||
// Previously this would fail with "Unknown phase subcommand"
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}\nOutput: ${result.output}`);
|
|
||||||
assert.ok(!result.error || !result.error.includes('Unknown phase subcommand'),
|
|
||||||
`got unexpected error: ${result.error}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns JSON with plan_count and plans array when plans exist', () => {
|
|
||||||
touch(phaseDir, '01-01-PLAN.md', '01-02-PLAN.md');
|
|
||||||
const result = runGsdTools(['query', 'phase.list-plans', '1', '--raw'], tmpDir);
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}\nOutput: ${result.output}`);
|
|
||||||
const data = JSON.parse(result.output);
|
|
||||||
assert.equal(data.plan_count, 2, 'plan_count should be 2');
|
|
||||||
assert.equal(data.has_plans, true, 'has_plans should be true');
|
|
||||||
assert.ok(Array.isArray(data.plans), 'plans should be an array');
|
|
||||||
assert.equal(data.plans.length, 2, 'plans array should have 2 entries');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns plan_count 0 and empty plans array when phase has no plan files', () => {
|
|
||||||
// Phase directory exists but has no *-PLAN.md files
|
|
||||||
touch(phaseDir, 'CONTEXT.md');
|
|
||||||
const result = runGsdTools(['query', 'phase.list-plans', '1', '--raw'], tmpDir);
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}\nOutput: ${result.output}`);
|
|
||||||
const data = JSON.parse(result.output);
|
|
||||||
assert.equal(data.plan_count, 0);
|
|
||||||
assert.equal(data.has_plans, false);
|
|
||||||
assert.deepEqual(data.plans, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns has_plans false when phase number is not found', () => {
|
|
||||||
// Phase 99 does not exist in the fixture
|
|
||||||
const result = runGsdTools(['query', 'phase.list-plans', '99', '--raw'], tmpDir);
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}\nOutput: ${result.output}`);
|
|
||||||
const data = JSON.parse(result.output);
|
|
||||||
assert.equal(data.has_plans, false);
|
|
||||||
assert.equal(data.plan_count, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('plan paths are relative to project root and posix-style', () => {
|
|
||||||
touch(phaseDir, '01-01-PLAN.md');
|
|
||||||
const result = runGsdTools(['query', 'phase.list-plans', '1', '--raw'], tmpDir);
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}\nOutput: ${result.output}`);
|
|
||||||
const data = JSON.parse(result.output);
|
|
||||||
assert.equal(data.plans.length, 1);
|
|
||||||
// Paths must be forward-slash separated (posix) and relative (not absolute)
|
|
||||||
const planPath = data.plans[0];
|
|
||||||
assert.ok(!path.isAbsolute(planPath), `expected relative path, got: ${planPath}`);
|
|
||||||
assert.ok(!planPath.includes('\\'), `expected posix path, got: ${planPath}`);
|
|
||||||
assert.ok(planPath.includes('01-01-PLAN.md'), `expected plan filename in path: ${planPath}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('dotted form phase.list-plans (without query prefix) also works', () => {
|
|
||||||
touch(phaseDir, '01-01-PLAN.md');
|
|
||||||
const result = runGsdTools(['phase.list-plans', '1', '--raw'], tmpDir);
|
|
||||||
assert.ok(result.success, `Command failed: ${result.error}\nOutput: ${result.output}`);
|
|
||||||
const data = JSON.parse(result.output);
|
|
||||||
assert.equal(data.plan_count, 1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -381,3 +381,539 @@ describe('stateReplaceFieldWithFallback field-miss warning', () => {
|
|||||||
assert.ok(typeof output.status === 'string', 'Should return a status string');
|
assert.ok(typeof output.status === 'string', 'Should return a status string');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
// Folded from tests/6-validate-cjs-drift-regression.test.cjs — consolidation epic #1969 (B2 #1971)
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const { describe: __foldDescribe } = require('node:test');
|
||||||
|
__foldDescribe("folded:6-validate-cjs-drift-regression (consolidation epic #1969 B2 #1971)", () => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression tests for issue #6 (open-gsd/gsd-core):
|
||||||
|
* Three validation behaviors present in validate.ts are missing from verify.cjs,
|
||||||
|
* producing silent false negatives on the CJS production path.
|
||||||
|
*
|
||||||
|
* Three drift items fixed by porting phaseVariants() and activeDiskPhases to verify.cjs:
|
||||||
|
*
|
||||||
|
* 1. W007 activeDiskPhases — verify.cjs uses diskPhases (includes archived) for the
|
||||||
|
* W007 check; validate.ts uses activeDiskPhases (active phasesDir only). Archived
|
||||||
|
* phases absent from current ROADMAP trigger false W007 in verify.cjs.
|
||||||
|
*
|
||||||
|
* 2. phaseVariants() normalization — validate.ts has a phaseVariants() function
|
||||||
|
* generating padded/unpadded/letter-suffix variants for matching. verify.cjs uses
|
||||||
|
* only parseInt→padded (drops letter suffix), causing false W006/W007 for
|
||||||
|
* letter-suffix phases with padding mismatch between ROADMAP and disk.
|
||||||
|
*
|
||||||
|
* 3. W006 unchecked-phase variant skip — same phaseVariants() gap causes false W006
|
||||||
|
* for phases with padding mismatch: ROADMAP says "3B", disk has "03B-foo", but
|
||||||
|
* verify.cjs padded("3B") = "03" (drops letter) → "03B" on disk not matched.
|
||||||
|
*
|
||||||
|
* References:
|
||||||
|
* - ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md)
|
||||||
|
* - Issue #6 (open-gsd/gsd-core)
|
||||||
|
* - PR #154 (issue #4) — precedent for the generator pattern
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { describe, test, before, after } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const os = require('node:os');
|
||||||
|
|
||||||
|
const { runGsdTools, cleanup } = require('./helpers.cjs');
|
||||||
|
|
||||||
|
// ── Fixture helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function mkplanning(base) {
|
||||||
|
const planningDir = path.join(base, '.planning');
|
||||||
|
const phasesDir = path.join(planningDir, 'phases');
|
||||||
|
fs.mkdirSync(phasesDir, { recursive: true });
|
||||||
|
return { planningDir, phasesDir };
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeProjectMd(planningDir) {
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(planningDir, 'PROJECT.md'),
|
||||||
|
'# Project\n\n## What This Is\nTest.\n\n## Core Value\nTest.\n\n## Requirements\nTest.\n',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStateMd(planningDir, phase = '2') {
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(planningDir, 'STATE.md'),
|
||||||
|
`# State\n\n**Current Phase:** ${phase}\n**Status:** In progress\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeConfigJson(planningDir) {
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(planningDir, 'config.json'),
|
||||||
|
JSON.stringify({ model_profile: 'balanced' }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Drift Item 1: W007 activeDiskPhases ────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Project has a shipped milestone archive (milestones/v1.0-phases/) with old phase
|
||||||
|
// "1" inside, and active phasesDir with phase "2". Current ROADMAP only mentions
|
||||||
|
// Phase 2 (v1.0 phases were shipped and removed from ROADMAP).
|
||||||
|
//
|
||||||
|
// validate.ts: activeDiskPhases = phases from active phasesDir only (not archived).
|
||||||
|
// "1" is only in diskPhases (via forEachArchivedPhaseToken), not activeDiskPhases.
|
||||||
|
// W007 iterates activeDiskPhases → "1" never checked → no false W007.
|
||||||
|
//
|
||||||
|
// verify.cjs pre-fix: diskPhases = collectDiskPhases() + forEachArchivedPhaseToken().
|
||||||
|
// diskPhases includes "1" (from old archive). W007 iterates diskPhases → "1" not
|
||||||
|
// in roadmapPhases → W007 fires for "1". False positive.
|
||||||
|
|
||||||
|
describe('Drift item 1 — W007 activeDiskPhases: no false W007 for archived phases', () => {
|
||||||
|
let tmpDir;
|
||||||
|
|
||||||
|
before(() => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-6-d1-'));
|
||||||
|
const { planningDir, phasesDir } = mkplanning(tmpDir);
|
||||||
|
writeProjectMd(planningDir);
|
||||||
|
writeStateMd(planningDir);
|
||||||
|
writeConfigJson(planningDir);
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(planningDir, 'ROADMAP.md'),
|
||||||
|
[
|
||||||
|
'# Roadmap',
|
||||||
|
'',
|
||||||
|
'- [x] **Phase 2:** API',
|
||||||
|
'',
|
||||||
|
'### Phase 2: API',
|
||||||
|
'',
|
||||||
|
'## Progress',
|
||||||
|
'| Phase | Status |',
|
||||||
|
'|-------|--------|',
|
||||||
|
'| 2 | Complete |',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Active phasesDir: only phase 2
|
||||||
|
fs.mkdirSync(path.join(phasesDir, '2-api'), { recursive: true });
|
||||||
|
|
||||||
|
// Current active milestone archive: v1.1-phases contains phase 2 (in ROADMAP)
|
||||||
|
fs.mkdirSync(
|
||||||
|
path.join(planningDir, 'milestones', 'v1.1-phases', '2-api'),
|
||||||
|
{ recursive: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Old shipped milestone archive: v1.0-phases contains phase 1 (no longer in ROADMAP)
|
||||||
|
// v1.0 sorts before v1.1 so getActiveMilestoneArchiveDir returns v1.1.
|
||||||
|
// forEachArchivedPhaseToken walks BOTH v1.0 and v1.1, so diskPhases gets "1".
|
||||||
|
// collectDiskPhases (activeDiskPhases) only uses v1.1 (active archive) — "1" excluded.
|
||||||
|
fs.mkdirSync(
|
||||||
|
path.join(planningDir, 'milestones', 'v1.0-phases', '1-foundation'),
|
||||||
|
{ recursive: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
cleanup(tmpDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no W007 for archived phase "1" absent from current ROADMAP', () => {
|
||||||
|
// validate.ts: activeDiskPhases has only "2" (from active phasesDir + v1.1 archive).
|
||||||
|
// "1" is in diskPhases (forEachArchivedPhaseToken walks v1.0) but NOT activeDiskPhases.
|
||||||
|
// W007 iterates activeDiskPhases → "1" never checked → no false W007. Correct.
|
||||||
|
// verify.cjs pre-fix: diskPhases = collectDiskPhases + forEachArchivedPhaseToken.
|
||||||
|
// diskPhases includes "1" (from v1.0 archive). W007 iterates diskPhases → "1" not
|
||||||
|
// in roadmapPhases → W007 fires for "1". False positive.
|
||||||
|
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
|
||||||
|
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
|
||||||
|
const data = JSON.parse(result.output);
|
||||||
|
const w007 = (data.warnings ?? []).filter((w) => w.code === 'W007');
|
||||||
|
// Filter to W007 that mentions only phase "1" (not "1A", "01A", etc.)
|
||||||
|
const w007Phase1 = w007.filter(
|
||||||
|
(w) => /\bPhase 1\b/i.test(w.message) && !/\b1[A-Z]\b/i.test(w.message),
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
w007Phase1.length,
|
||||||
|
0,
|
||||||
|
`Expected no W007 for archived phase 1 (v1.0 archive), got: ${JSON.stringify(w007)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Drift Item 2: phaseVariants() normalization ────────────────────────────
|
||||||
|
//
|
||||||
|
// ROADMAP has "### Phase 01A:" (zero-padded letter-suffix heading).
|
||||||
|
// Disk has directory "1A-foo" (unpadded letter-suffix form).
|
||||||
|
// These should match because phaseVariants("01A") = {"01A", "1A", "01A"}.
|
||||||
|
//
|
||||||
|
// validate.ts: diskPhases has "1A". phaseVariants("01A") includes "1A" → match → no W006.
|
||||||
|
// activeDiskPhases has "1A". phaseVariants("1A") includes "01A" → roadmapPhaseVariants
|
||||||
|
// has "01A" → match → no W007.
|
||||||
|
//
|
||||||
|
// verify.cjs pre-fix:
|
||||||
|
// W006 loop for "01A": padded = String(parseInt("01A",10)).padStart(2,'0') = "01".
|
||||||
|
// diskPhases.has("01A")? NO. diskPhases.has("01")? NO. → W006 fires. Bug.
|
||||||
|
// W007 loop for "1A": unpadded = String(parseInt("1A",10)) = "1".
|
||||||
|
// roadmapPhases.has("1A")? NO. roadmapPhases.has("1")? NO. → W007 fires. Bug.
|
||||||
|
|
||||||
|
describe('Drift item 2 — phaseVariants() normalization: letter-suffix zero-padding mismatch', () => {
|
||||||
|
let tmpDir;
|
||||||
|
|
||||||
|
before(() => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-6-d2-'));
|
||||||
|
const { planningDir, phasesDir } = mkplanning(tmpDir);
|
||||||
|
writeProjectMd(planningDir);
|
||||||
|
writeStateMd(planningDir);
|
||||||
|
writeConfigJson(planningDir);
|
||||||
|
|
||||||
|
// ROADMAP: Phase 01A (zero-padded + letter suffix)
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(planningDir, 'ROADMAP.md'),
|
||||||
|
[
|
||||||
|
'# Roadmap',
|
||||||
|
'',
|
||||||
|
'- [x] **Phase 01A:** Suffix Phase',
|
||||||
|
'',
|
||||||
|
'### Phase 01A: Suffix Phase',
|
||||||
|
'',
|
||||||
|
'## Progress',
|
||||||
|
'| Phase | Status |',
|
||||||
|
'|-------|--------|',
|
||||||
|
'| 01A | Complete |',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Disk: unpadded form "1A-foo"
|
||||||
|
fs.mkdirSync(path.join(phasesDir, '1A-suffix-phase'), { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
cleanup(tmpDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no false W006 when ROADMAP says 01A and disk has 1A-... (phaseVariants normalizes)', () => {
|
||||||
|
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
|
||||||
|
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
|
||||||
|
const data = JSON.parse(result.output);
|
||||||
|
const w006 = (data.warnings ?? []).filter((w) => w.code === 'W006');
|
||||||
|
assert.strictEqual(
|
||||||
|
w006.length,
|
||||||
|
0,
|
||||||
|
`Expected no W006 (01A == 1A after normalization), got: ${JSON.stringify(w006)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no false W007 when disk has 1A and ROADMAP says 01A (phaseVariants normalizes)', () => {
|
||||||
|
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
|
||||||
|
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
|
||||||
|
const data = JSON.parse(result.output);
|
||||||
|
const w007 = (data.warnings ?? []).filter((w) => w.code === 'W007');
|
||||||
|
assert.strictEqual(
|
||||||
|
w007.length,
|
||||||
|
0,
|
||||||
|
`Expected no W007 (1A on disk matches 01A in ROADMAP), got: ${JSON.stringify(w007)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Drift Item 3: W006 false positive when disk uses zero-padded letter form ─
|
||||||
|
//
|
||||||
|
// ROADMAP has "### Phase 3B:" (unpadded letter-suffix heading).
|
||||||
|
// Disk has directory "03B-feature" (zero-padded letter-suffix form).
|
||||||
|
//
|
||||||
|
// validate.ts:
|
||||||
|
// diskPhases has "03B". phaseVariants("3B") = {"3B","3B","03B"}.
|
||||||
|
// existsOnDisk = diskPhases.has("03B") = TRUE → no W006.
|
||||||
|
// activeDiskPhases has "03B". phaseVariants("03B") = {"03B","3B","03B"}.
|
||||||
|
// roadmapPhaseVariants has {"3B","3B","03B"} → "03B" found → no W007.
|
||||||
|
//
|
||||||
|
// verify.cjs pre-fix:
|
||||||
|
// W006 for "3B": padded = parseInt("3B")=3 → "03" (drops "B").
|
||||||
|
// diskPhases.has("3B")? NO. diskPhases.has("03")? NO (dir is "03B" not "03"). → W006 fires.
|
||||||
|
// W007 for "03B": unpadded = String(parseInt("03B",10)) = "3".
|
||||||
|
// roadmapPhases.has("03B")? NO. roadmapPhases.has("3")? NO (ROADMAP has "3B" not "3"). → W007.
|
||||||
|
|
||||||
|
describe('Drift item 3 — W006 false positive when disk has zero-padded letter form', () => {
|
||||||
|
let tmpDir;
|
||||||
|
|
||||||
|
before(() => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-6-d3-'));
|
||||||
|
const { planningDir, phasesDir } = mkplanning(tmpDir);
|
||||||
|
writeProjectMd(planningDir);
|
||||||
|
writeStateMd(planningDir, '3B');
|
||||||
|
writeConfigJson(planningDir);
|
||||||
|
|
||||||
|
// ROADMAP: Phase 3B (unpadded in heading)
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(planningDir, 'ROADMAP.md'),
|
||||||
|
[
|
||||||
|
'# Roadmap',
|
||||||
|
'',
|
||||||
|
'- [x] **Phase 3B:** Feature Extension',
|
||||||
|
'',
|
||||||
|
'### Phase 3B: Feature Extension',
|
||||||
|
'',
|
||||||
|
'## Progress',
|
||||||
|
'| Phase | Status |',
|
||||||
|
'|-------|--------|',
|
||||||
|
'| 3B | Complete |',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Disk: "03B-feature" (zero-padded letter-suffix form)
|
||||||
|
fs.mkdirSync(path.join(phasesDir, '03B-feature'), { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
cleanup(tmpDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no false W006 when ROADMAP says 3B and disk has 03B-... (phaseVariants covers zero-padded)', () => {
|
||||||
|
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
|
||||||
|
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
|
||||||
|
const data = JSON.parse(result.output);
|
||||||
|
const w006 = (data.warnings ?? []).filter((w) => w.code === 'W006');
|
||||||
|
assert.strictEqual(
|
||||||
|
w006.length,
|
||||||
|
0,
|
||||||
|
`Expected no W006 (3B == 03B after normalization), got: ${JSON.stringify(w006)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no false W007 when disk has 03B and ROADMAP says 3B (phaseVariants covers both forms)', () => {
|
||||||
|
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
|
||||||
|
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
|
||||||
|
const data = JSON.parse(result.output);
|
||||||
|
const w007 = (data.warnings ?? []).filter((w) => w.code === 'W007');
|
||||||
|
assert.strictEqual(
|
||||||
|
w007.length,
|
||||||
|
0,
|
||||||
|
`Expected no W007 (03B on disk matches 3B in ROADMAP), got: ${JSON.stringify(w007)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
// Folded from tests/bug-416-archive-dir-null.test.cjs — consolidation epic #1969 (B2 #1971)
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const { describe: __foldDescribe } = require('node:test');
|
||||||
|
__foldDescribe("folded:bug-416-archive-dir-null (consolidation epic #1969 B2 #1971)", () => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression tests for issue #416 (open-gsd/gsd-core).
|
||||||
|
*
|
||||||
|
* Bug: getActiveMilestoneArchiveDir falls back to the newest archive directory
|
||||||
|
* when STATE.md names a milestone that has no matching archive yet, producing
|
||||||
|
* W007 false positives for phases from a prior (completed) milestone.
|
||||||
|
*
|
||||||
|
* Fix: when STATE.md is present and parseable and names a milestone, but no
|
||||||
|
* milestones/<vX.Y>-phases/ directory matches, return null. The version-sort
|
||||||
|
* fallback to the newest archive fires only when STATE.md is absent or
|
||||||
|
* unparseable.
|
||||||
|
*
|
||||||
|
* Knuth invariant: the resolver answers one question —
|
||||||
|
* "what archive directory holds the active milestone's phases?"
|
||||||
|
* Answer space: <dir> | null.
|
||||||
|
*/
|
||||||
|
|
||||||
|
process.env.GSD_TEST_MODE = '1';
|
||||||
|
|
||||||
|
const { describe, test, before, after } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const os = require('node:os');
|
||||||
|
|
||||||
|
const { runGsdTools, cleanup } = require('./helpers.cjs');
|
||||||
|
|
||||||
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function mkplanning(base) {
|
||||||
|
const planningDir = path.join(base, '.planning');
|
||||||
|
const phasesDir = path.join(planningDir, 'phases');
|
||||||
|
fs.mkdirSync(phasesDir, { recursive: true });
|
||||||
|
return planningDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeMinimalRoadmap(planningDir, phases) {
|
||||||
|
// phases: array of { num, name, checked }
|
||||||
|
const checkboxes = phases.map(({ num, name, checked }) =>
|
||||||
|
`- [${checked ? 'x' : ' '}] **Phase ${num}:** ${name}`,
|
||||||
|
).join('\n');
|
||||||
|
const headings = phases.map(({ num, name }) =>
|
||||||
|
`### Phase ${num}: ${name}\n**Goal:** Completed.\n`,
|
||||||
|
).join('\n');
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(planningDir, 'ROADMAP.md'),
|
||||||
|
`# Roadmap\n\n${checkboxes}\n\n${headings}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStateMdMilestone(planningDir, milestone) {
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(planningDir, 'STATE.md'),
|
||||||
|
`# State\n\n**milestone:** ${milestone}\n**Current Phase:** 23\n**Status:** In progress\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeProjectMd(planningDir) {
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(planningDir, 'PROJECT.md'),
|
||||||
|
'# Project\n\n## What This Is\nTest.\n\n## Core Value\nTest.\n\n## Requirements\nTest.\n',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeConfigJson(planningDir) {
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(planningDir, 'config.json'),
|
||||||
|
JSON.stringify({ model_profile: 'balanced' }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mkArchivePhases(planningDir, version, phaseNums) {
|
||||||
|
// Creates .planning/milestones/<version>-phases/<NN>-phase-name/ dirs
|
||||||
|
const archiveDir = path.join(planningDir, 'milestones', `${version}-phases`);
|
||||||
|
for (const num of phaseNums) {
|
||||||
|
const padded = String(num).padStart(2, '0');
|
||||||
|
fs.mkdirSync(path.join(archiveDir, `${padded}-phase-${num}`), { recursive: true });
|
||||||
|
}
|
||||||
|
return archiveDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Case 1: STATE.md milestone: v6.0, only v5.0-phases/ on disk
|
||||||
|
// → resolver returns null, verifier emits zero W007 for phases 17–22
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('bug #416 case 1: STATE.md v6.0 with only v5.0-phases/ on disk → null, no W007', () => {
|
||||||
|
let tmpDir;
|
||||||
|
|
||||||
|
before(() => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-416-c1-'));
|
||||||
|
const planningDir = mkplanning(tmpDir);
|
||||||
|
writeProjectMd(planningDir);
|
||||||
|
writeConfigJson(planningDir);
|
||||||
|
|
||||||
|
// Active milestone is v6.0 — no archive for it yet (phases live in flat phases/)
|
||||||
|
writeStateMdMilestone(planningDir, 'v6.0');
|
||||||
|
|
||||||
|
// v5.0 was the prior completed milestone; its archive exists on disk
|
||||||
|
mkArchivePhases(planningDir, 'v5.0', [17, 18, 19, 20, 21, 22]);
|
||||||
|
|
||||||
|
// ROADMAP reflects only v6.0 phases (v5.0 phases are in a prior milestone,
|
||||||
|
// not in the current roadmap section)
|
||||||
|
writeMinimalRoadmap(planningDir, [
|
||||||
|
{ num: 23, name: 'New Foundation', checked: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => { cleanup(tmpDir); });
|
||||||
|
|
||||||
|
test('validate health emits zero W007 warnings (no prior-milestone phases surfaced)', () => {
|
||||||
|
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
|
||||||
|
assert.strictEqual(result.success, true, `validate health failed: ${result.error}`);
|
||||||
|
const data = JSON.parse(result.output);
|
||||||
|
const w007 = (data.warnings ?? []).filter((w) => w.code === 'W007');
|
||||||
|
assert.strictEqual(
|
||||||
|
w007.length,
|
||||||
|
0,
|
||||||
|
`Expected zero W007 — phases 17–22 from v5.0-phases/ must not appear as "active".\nGot: ${JSON.stringify(w007, null, 2)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Case 2: No STATE.md, multiple archives on disk → version-sort fallback
|
||||||
|
// returns the highest-versioned archive (existing behavior preserved)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('bug #416 case 2: no STATE.md + multiple archives → version-sort fallback to newest', () => {
|
||||||
|
let tmpDir;
|
||||||
|
|
||||||
|
before(() => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-416-c2-'));
|
||||||
|
const planningDir = mkplanning(tmpDir);
|
||||||
|
writeProjectMd(planningDir);
|
||||||
|
writeConfigJson(planningDir);
|
||||||
|
|
||||||
|
// No STATE.md — resolver must use the version-sort fallback
|
||||||
|
// Two archives: v4.0 and v5.0; v5.0 is newer
|
||||||
|
mkArchivePhases(planningDir, 'v4.0', [10, 11, 12]);
|
||||||
|
mkArchivePhases(planningDir, 'v5.0', [17, 18, 19]);
|
||||||
|
|
||||||
|
// ROADMAP lists v5.0 phases so W007 does not fire
|
||||||
|
writeMinimalRoadmap(planningDir, [
|
||||||
|
{ num: 17, name: 'Alpha', checked: true },
|
||||||
|
{ num: 18, name: 'Beta', checked: true },
|
||||||
|
{ num: 19, name: 'Gamma', checked: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => { cleanup(tmpDir); });
|
||||||
|
|
||||||
|
test('validate health succeeds and does not emit W007 for v5.0 archive phases', () => {
|
||||||
|
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
|
||||||
|
assert.strictEqual(result.success, true, `validate health failed: ${result.error}`);
|
||||||
|
const data = JSON.parse(result.output);
|
||||||
|
const w007 = (data.warnings ?? []).filter((w) => w.code === 'W007');
|
||||||
|
// v5.0 phases (17–19) are in the archive returned by the fallback and
|
||||||
|
// in the ROADMAP, so no W007 should fire.
|
||||||
|
assert.strictEqual(
|
||||||
|
w007.length,
|
||||||
|
0,
|
||||||
|
`Expected zero W007 for v5.0 archive phases present in ROADMAP.\nGot: ${JSON.stringify(w007, null, 2)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Case 3: STATE.md milestone: v5.0, matching v5.0-phases/ exists → returns it
|
||||||
|
// (regression guard — happy path must not break)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('bug #416 case 3: STATE.md v5.0 with matching v5.0-phases/ → returns archive dir', () => {
|
||||||
|
let tmpDir;
|
||||||
|
|
||||||
|
before(() => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-416-c3-'));
|
||||||
|
const planningDir = mkplanning(tmpDir);
|
||||||
|
writeProjectMd(planningDir);
|
||||||
|
writeConfigJson(planningDir);
|
||||||
|
|
||||||
|
// STATE.md names v5.0 and a matching archive exists
|
||||||
|
writeStateMdMilestone(planningDir, 'v5.0');
|
||||||
|
mkArchivePhases(planningDir, 'v5.0', [17, 18, 19, 20, 21, 22]);
|
||||||
|
|
||||||
|
// ROADMAP lists v5.0 phases so W007 does not fire
|
||||||
|
writeMinimalRoadmap(planningDir, [
|
||||||
|
{ num: 17, name: 'Alpha', checked: true },
|
||||||
|
{ num: 18, name: 'Beta', checked: true },
|
||||||
|
{ num: 19, name: 'Gamma', checked: true },
|
||||||
|
{ num: 20, name: 'Delta', checked: true },
|
||||||
|
{ num: 21, name: 'Epsilon', checked: true },
|
||||||
|
{ num: 22, name: 'Zeta', checked: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => { cleanup(tmpDir); });
|
||||||
|
|
||||||
|
test('validate health emits zero W007 — archive phases are in ROADMAP and active', () => {
|
||||||
|
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
|
||||||
|
assert.strictEqual(result.success, true, `validate health failed: ${result.error}`);
|
||||||
|
const data = JSON.parse(result.output);
|
||||||
|
const w007 = (data.warnings ?? []).filter((w) => w.code === 'W007');
|
||||||
|
assert.strictEqual(
|
||||||
|
w007.length,
|
||||||
|
0,
|
||||||
|
`Expected zero W007 for matching v5.0 archive with v5.0 in STATE.md.\nGot: ${JSON.stringify(w007, null, 2)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -805,3 +805,343 @@ describe('init manager — cross-milestone dependency satisfaction (#2267)', ()
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
// Folded from tests/bug-3584-runtime-slash-emitters.test.cjs — consolidation epic #1969 (B2 #1971)
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const { describe: __foldDescribe } = require('node:test');
|
||||||
|
__foldDescribe("folded:bug-3584-runtime-slash-emitters (consolidation epic #1969 B2 #1971)", () => {
|
||||||
|
/**
|
||||||
|
* Regression tests for bug #3584 — runtime emitters use the slash formatter.
|
||||||
|
*
|
||||||
|
* These tests exercise the actual runtime command handlers via `runGsdTools`
|
||||||
|
* and assert on the structured payloads they emit/persist. They prove that
|
||||||
|
* the high-impact emitters identified in the issue (`init.cjs` recommended
|
||||||
|
* actions, `phase.cjs` ROADMAP persistence, `verify.cjs` remediation hints,
|
||||||
|
* `milestone.cjs` Operator-Next-Steps persistence, `validate-command-router`
|
||||||
|
* fracture recommendations) no longer emit the unroutable `/gsd:<cmd>` colon
|
||||||
|
* form for skills-based runtimes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const {
|
||||||
|
runGsdTools,
|
||||||
|
createTempProject,
|
||||||
|
createTempDir,
|
||||||
|
cleanup,
|
||||||
|
} = require('./helpers.cjs');
|
||||||
|
|
||||||
|
// Helper: assert no string field anywhere in `value` (recursive) contains '/gsd:'.
|
||||||
|
function assertNoColonForm(value, label) {
|
||||||
|
const stack = [{ v: value, p: label }];
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const { v, p } = stack.pop();
|
||||||
|
if (typeof v === 'string') {
|
||||||
|
assert.ok(
|
||||||
|
!v.includes('/gsd:'),
|
||||||
|
`${p}: must not contain deprecated /gsd: form, got ${JSON.stringify(v)}`,
|
||||||
|
);
|
||||||
|
} else if (Array.isArray(v)) {
|
||||||
|
v.forEach((item, i) => stack.push({ v: item, p: `${p}[${i}]` }));
|
||||||
|
} else if (v && typeof v === 'object') {
|
||||||
|
for (const key of Object.keys(v)) {
|
||||||
|
stack.push({ v: v[key], p: `${p}.${key}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: collect every `command` string under a recommendedActions-style array.
|
||||||
|
function collectCommandFields(value) {
|
||||||
|
const out = [];
|
||||||
|
const stack = [value];
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const v = stack.pop();
|
||||||
|
if (Array.isArray(v)) {
|
||||||
|
for (const item of v) stack.push(item);
|
||||||
|
} else if (v && typeof v === 'object') {
|
||||||
|
if (typeof v.command === 'string') out.push(v.command);
|
||||||
|
for (const key of Object.keys(v)) stack.push(v[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('bug-3584: init manager recommendedActions emit hyphen form', () => {
|
||||||
|
let tmpDir;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = createTempProject();
|
||||||
|
// Minimal ROADMAP + STATE so init manager will run.
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||||
|
[
|
||||||
|
'# Roadmap',
|
||||||
|
'',
|
||||||
|
'## Milestone v1',
|
||||||
|
'',
|
||||||
|
'### Phase 1: Foundation',
|
||||||
|
'**Goal:** scaffold',
|
||||||
|
'**Plans:** 1 plan',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||||
|
'# State\n\nCurrent Phase: 1\n',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => cleanup(tmpDir));
|
||||||
|
|
||||||
|
test('skills runtime (claude) emits /gsd-<cmd> in recommended_actions[].command', () => {
|
||||||
|
// Plan-but-not-executed: directory exists with a PLAN.md → execute is recommended.
|
||||||
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||||||
|
fs.mkdirSync(phaseDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
|
||||||
|
|
||||||
|
const result = runGsdTools('init manager', tmpDir, { GSD_RUNTIME: 'claude' });
|
||||||
|
assert.ok(result.success, `init manager failed: ${result.error || result.output}`);
|
||||||
|
|
||||||
|
const payload = JSON.parse(result.output);
|
||||||
|
const commands = collectCommandFields(payload.recommended_actions || []);
|
||||||
|
assert.ok(commands.length > 0, 'recommended_actions should produce at least one command');
|
||||||
|
|
||||||
|
for (const cmd of commands) {
|
||||||
|
assert.ok(
|
||||||
|
cmd.startsWith('/gsd-'),
|
||||||
|
`recommended_actions command must start with /gsd- for skills-based runtimes, got ${cmd}`,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
!cmd.includes('/gsd:'),
|
||||||
|
`recommended_actions command must not contain /gsd: colon form, got ${cmd}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assertNoColonForm(payload, 'init.manager payload');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('codex runtime emits $gsd-<cmd> in recommended_actions[].command', () => {
|
||||||
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||||||
|
fs.mkdirSync(phaseDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
|
||||||
|
|
||||||
|
const result = runGsdTools('init manager', tmpDir, { GSD_RUNTIME: 'codex' });
|
||||||
|
assert.ok(result.success, `init manager (codex) failed: ${result.error || result.output}`);
|
||||||
|
|
||||||
|
const payload = JSON.parse(result.output);
|
||||||
|
const commands = collectCommandFields(payload.recommended_actions || []);
|
||||||
|
assert.ok(commands.length > 0);
|
||||||
|
|
||||||
|
for (const cmd of commands) {
|
||||||
|
assert.ok(
|
||||||
|
cmd.startsWith('$gsd-'),
|
||||||
|
`codex recommended_actions command must use $gsd- shell-var form, got ${cmd}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('codex alias runtime emits $gsd-<cmd> in recommended_actions[].command', () => {
|
||||||
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||||||
|
fs.mkdirSync(phaseDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
|
||||||
|
|
||||||
|
const result = runGsdTools('init manager', tmpDir, { GSD_RUNTIME: 'codex-app' });
|
||||||
|
assert.ok(result.success, `init manager (codex-app) failed: ${result.error || result.output}`);
|
||||||
|
|
||||||
|
const payload = JSON.parse(result.output);
|
||||||
|
const commands = collectCommandFields(payload.recommended_actions || []);
|
||||||
|
assert.ok(commands.length > 0);
|
||||||
|
|
||||||
|
for (const cmd of commands) {
|
||||||
|
assert.ok(
|
||||||
|
cmd.startsWith('$gsd-'),
|
||||||
|
`codex alias recommended_actions command must use $gsd- shell-var form, got ${cmd}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bug-3584: phase add persists hyphen form into ROADMAP.md', () => {
|
||||||
|
let tmpDir;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = createTempProject();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||||
|
'# Roadmap\n\n## Milestone v1\n\n',
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||||
|
'# State\n',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => cleanup(tmpDir));
|
||||||
|
|
||||||
|
test('phase add persists hyphen-form slash command in roadmap get-phase payload', () => {
|
||||||
|
// 1. Add a phase — the system under test persists the phase entry into ROADMAP.md.
|
||||||
|
const addResult = runGsdTools(
|
||||||
|
['phase', 'add', 'Test new feature'],
|
||||||
|
tmpDir,
|
||||||
|
{ GSD_RUNTIME: 'claude' },
|
||||||
|
);
|
||||||
|
assert.ok(addResult.success, `phase add failed: ${addResult.error || addResult.output}`);
|
||||||
|
const addPayload = JSON.parse(addResult.output);
|
||||||
|
assert.ok(addPayload.phase_number, 'phase add must return phase_number');
|
||||||
|
|
||||||
|
// 2. Read the persisted section back via the structured roadmap-get-phase
|
||||||
|
// contract (NOT via readFileSync; the `section` field on the JSON payload
|
||||||
|
// is the runtime's typed projection of the on-disk ROADMAP content).
|
||||||
|
const getResult = runGsdTools(
|
||||||
|
['roadmap', 'get-phase', String(addPayload.phase_number)],
|
||||||
|
tmpDir,
|
||||||
|
{ GSD_RUNTIME: 'claude' },
|
||||||
|
);
|
||||||
|
assert.ok(getResult.success, `roadmap get-phase failed: ${getResult.error || getResult.output}`);
|
||||||
|
const getPayload = JSON.parse(getResult.output);
|
||||||
|
assert.ok(getPayload.found, 'roadmap get-phase must find the newly-added phase');
|
||||||
|
assert.ok(typeof getPayload.section === 'string', 'roadmap get-phase must return a section field');
|
||||||
|
|
||||||
|
// 3. The persisted phase section must use the routable hyphen form.
|
||||||
|
assert.ok(
|
||||||
|
!getPayload.section.includes('/gsd:plan-phase'),
|
||||||
|
`persisted phase section must not contain /gsd:plan-phase, got: ${getPayload.section}`,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
getPayload.section.includes('/gsd-plan-phase'),
|
||||||
|
`persisted phase section must contain /gsd-plan-phase, got: ${getPayload.section}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bug-3584: validate health emits hyphen form in fix hints', () => {
|
||||||
|
test('validate health on a broken project returns hyphen-form fix strings', (t) => {
|
||||||
|
const tmpDir = createTempDir();
|
||||||
|
t.after(() => cleanup(tmpDir));
|
||||||
|
// No .planning directory → E001 fires with a `fix:` string that contains
|
||||||
|
// the slash-command form.
|
||||||
|
|
||||||
|
const result = runGsdTools(
|
||||||
|
['validate', 'health'],
|
||||||
|
tmpDir,
|
||||||
|
{ GSD_RUNTIME: 'claude' },
|
||||||
|
);
|
||||||
|
// validate health exits non-zero on broken projects but still emits JSON to stdout.
|
||||||
|
const stdout = result.output;
|
||||||
|
let payload;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(stdout);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`validate health did not emit parseable JSON: ${stdout.slice(0, 400)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allIssues = []
|
||||||
|
.concat(payload.errors || [])
|
||||||
|
.concat(payload.warnings || [])
|
||||||
|
.concat(payload.info || []);
|
||||||
|
|
||||||
|
assert.ok(allIssues.length > 0, 'validate health on a bare tmpdir should report at least one issue');
|
||||||
|
|
||||||
|
const fixesWithSlash = allIssues
|
||||||
|
.map((i) => i.fix)
|
||||||
|
.filter((f) => typeof f === 'string' && /gsd[-:]/.test(f));
|
||||||
|
assert.ok(
|
||||||
|
fixesWithSlash.length > 0,
|
||||||
|
'at least one fix hint should reference a /gsd- slash command',
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const fix of fixesWithSlash) {
|
||||||
|
assert.ok(
|
||||||
|
!fix.includes('/gsd:'),
|
||||||
|
`validate health fix hint must not contain /gsd: colon form, got ${JSON.stringify(fix)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assertNoColonForm(payload, 'validate health payload');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bug-3584: validate context recommendation uses hyphen form', () => {
|
||||||
|
test('warning/critical recommendations emit /gsd-thread', (t) => {
|
||||||
|
const tmpDir = createTempProject();
|
||||||
|
t.after(() => cleanup(tmpDir));
|
||||||
|
|
||||||
|
// Critical band: 75% utilization. validate context emits JSON only with --json.
|
||||||
|
const result = runGsdTools(
|
||||||
|
['validate', 'context', '--tokens-used', '75000', '--context-window', '100000', '--json'],
|
||||||
|
tmpDir,
|
||||||
|
{ GSD_RUNTIME: 'claude' },
|
||||||
|
);
|
||||||
|
assert.ok(result.success, `validate context failed: ${result.error || result.output}`);
|
||||||
|
|
||||||
|
const payload = JSON.parse(result.output);
|
||||||
|
assert.ok(payload.recommendation, 'critical utilization should produce a recommendation');
|
||||||
|
assert.ok(
|
||||||
|
!payload.recommendation.includes('/gsd:'),
|
||||||
|
`recommendation must not contain /gsd:, got ${JSON.stringify(payload.recommendation)}`,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
payload.recommendation.includes('/gsd-thread'),
|
||||||
|
`recommendation must reference /gsd-thread, got ${JSON.stringify(payload.recommendation)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bug-3584: validate health uses formatter for codex runtime too', () => {
|
||||||
|
test('validate health under codex emits $gsd-<cmd> in fix strings (positive assertion)', (t) => {
|
||||||
|
const tmpDir = createTempDir();
|
||||||
|
t.after(() => cleanup(tmpDir));
|
||||||
|
|
||||||
|
const result = runGsdTools(
|
||||||
|
['validate', 'health'],
|
||||||
|
tmpDir,
|
||||||
|
{ GSD_RUNTIME: 'codex' },
|
||||||
|
);
|
||||||
|
let payload;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(result.output);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`validate health (codex) did not emit JSON: ${result.output.slice(0, 400)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allIssues = []
|
||||||
|
.concat(payload.errors || [])
|
||||||
|
.concat(payload.warnings || [])
|
||||||
|
.concat(payload.info || []);
|
||||||
|
|
||||||
|
// Collect fixes that mention any gsd slash-command form so we can lock
|
||||||
|
// both the absence of legacy forms AND the presence of the codex shape.
|
||||||
|
const fixesWithGsdRef = allIssues
|
||||||
|
.map((i) => i.fix)
|
||||||
|
.filter((f) => typeof f === 'string' && /(?:\$|\/)gsd[-:]/.test(f));
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
fixesWithGsdRef.length > 0,
|
||||||
|
'validate health on a bare tmpdir must produce at least one fix hint referencing a gsd command',
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const fix of fixesWithGsdRef) {
|
||||||
|
assert.ok(
|
||||||
|
fix.includes('$gsd-'),
|
||||||
|
`codex validate health fix must use shell-var $gsd- form, got ${JSON.stringify(fix)}`,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
!fix.includes('/gsd:'),
|
||||||
|
`codex validate health fix must not contain /gsd: colon form, got ${JSON.stringify(fix)}`,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
!fix.includes('/gsd-'),
|
||||||
|
`codex validate health fix must not contain /gsd- (skills) form, got ${JSON.stringify(fix)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -799,3 +799,105 @@ describe('#1871 — milestone complete archives phase dirs by default', () => {
|
|||||||
assert.ok(fs.existsSync(phaseDir), 'phase dir should remain in place with --no-archive-phases');
|
assert.ok(fs.existsSync(phaseDir), 'phase dir should remain in place with --no-archive-phases');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
// Folded from tests/bug-978-milestone-complete-force.test.cjs — consolidation epic #1969 (B2 #1971)
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const { describe: __foldDescribe } = require('node:test');
|
||||||
|
__foldDescribe("folded:bug-978-milestone-complete-force (consolidation epic #1969 B2 #1971)", () => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression test for bug #978: `gsd-tools milestone complete --force` was a
|
||||||
|
* dead flag. The milestone source (src/milestone.cts) has a guard that checks
|
||||||
|
* `options.force` and tells users to "Re-run with --force to override", but the
|
||||||
|
* CLI dispatcher (gsd-core/bin/gsd-tools.cjs) never parsed `--force` and never
|
||||||
|
* passed it into the options object. So `options.force` was always `undefined`
|
||||||
|
* and the guard could never be overridden regardless of what the user typed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a fixture where the guard will fire:
|
||||||
|
* - STATE.md has `milestone: <version>` so the guard's version-match check is
|
||||||
|
* satisfied.
|
||||||
|
* - ROADMAP.md lists a `### Phase 2: Real Work` heading for that milestone, but
|
||||||
|
* there is NO on-disk phase directory for it.
|
||||||
|
*
|
||||||
|
* This guarantees "unstarted phase" detection without touching any real phases.
|
||||||
|
* NOTE: the unstarted phase must be a REAL phase number — Phase 0 and Phase 999
|
||||||
|
* are backlog/pre-milestone sentinels that are intentionally excluded from this
|
||||||
|
* guard (#1580), so they would not fire it.
|
||||||
|
*/
|
||||||
|
function makeGuardFixture(tmpDir, version) {
|
||||||
|
// STATE.md with frontmatter milestone field matching the version
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||||
|
`---\nmilestone: ${version}\n---\n# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ROADMAP.md — the heading must include the version so getMilestonePhaseFilter
|
||||||
|
// does not return missingExplicitVersion. Phase 2 has no on-disk dir.
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||||
|
`# Roadmap ${version}\n\n### Phase 2: Real Work\n**Goal:** Not started\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('bug-978: milestone complete --force overrides unstarted-phase guard', () => {
|
||||||
|
let tmpDir;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = createTempProject('gsd-bug-978-');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup(tmpDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('without --force the guard fires and emits the documented error message', () => {
|
||||||
|
makeGuardFixture(tmpDir, 'v1.0');
|
||||||
|
|
||||||
|
const result = runGsdTools(
|
||||||
|
['milestone', 'complete', 'v1.0', '--name', 'Regression Test'],
|
||||||
|
tmpDir,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.strictEqual(result.success, false, 'command should fail without --force');
|
||||||
|
assert.ok(
|
||||||
|
result.error.includes('Re-run with --force to override'),
|
||||||
|
`expected guard error message; got: ${result.error}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('with --force the guard is bypassed and the command succeeds', () => {
|
||||||
|
makeGuardFixture(tmpDir, 'v1.0');
|
||||||
|
|
||||||
|
const result = runGsdTools(
|
||||||
|
['milestone', 'complete', 'v1.0', '--name', 'Regression Test', '--force'],
|
||||||
|
tmpDir,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
result.success,
|
||||||
|
`command should succeed with --force but failed: ${result.error}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const output = JSON.parse(result.output);
|
||||||
|
assert.strictEqual(output.version, 'v1.0');
|
||||||
|
// Milestone entry should have been created even though phase 2 has no dir
|
||||||
|
assert.ok(
|
||||||
|
fs.existsSync(path.join(tmpDir, '.planning', 'MILESTONES.md')),
|
||||||
|
'MILESTONES.md should have been created',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -421,3 +421,138 @@ describe('phase-command-router — integration: real hub + CJS phase handler', (
|
|||||||
assert.deepEqual(calls[0].descriptions, ['Alpha', 'Beta']);
|
assert.deepEqual(calls[0].descriptions, ['Alpha', 'Beta']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
// Folded from tests/fix-1437-phase-list-plans.test.cjs — consolidation epic #1969 (B2 #1971)
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const { describe: __foldDescribe } = require('node:test');
|
||||||
|
__foldDescribe("folded:fix-1437-phase-list-plans (consolidation epic #1969 B2 #1971)", () => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression tests for `gsd-tools query phase.list-plans <N>` (#1437).
|
||||||
|
*
|
||||||
|
* Prior to this fix, `phase.list-plans` was not registered in the
|
||||||
|
* phase-command-router, so any invocation produced:
|
||||||
|
* "Error: Unknown phase subcommand. Available: uat-passed, next-decimal, ..."
|
||||||
|
*
|
||||||
|
* These tests exercise the full dispatch path:
|
||||||
|
* gsd-tools → phase-command-router → phase.cmdPhaseListPlans
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||||
|
|
||||||
|
// ─── Fixture helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function setupProject(phaseSlug = '01-feature') {
|
||||||
|
const tmpDir = createTempProject();
|
||||||
|
// Minimal ROADMAP so findPhaseInternal can resolve the phase directory
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||||
|
[
|
||||||
|
'# Roadmap',
|
||||||
|
'',
|
||||||
|
'- [ ] Phase 1: Feature',
|
||||||
|
'',
|
||||||
|
'### Phase 1: Feature',
|
||||||
|
'**Goal:** Build feature',
|
||||||
|
'**Plans:** 1 plans',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', phaseSlug);
|
||||||
|
fs.mkdirSync(phaseDir, { recursive: true });
|
||||||
|
return { tmpDir, phaseDir };
|
||||||
|
}
|
||||||
|
|
||||||
|
function touch(dir, ...files) {
|
||||||
|
for (const f of files) {
|
||||||
|
fs.writeFileSync(path.join(dir, f), '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let tmpDir;
|
||||||
|
let phaseDir;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const proj = setupProject('01-feature');
|
||||||
|
tmpDir = proj.tmpDir;
|
||||||
|
phaseDir = proj.phaseDir;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup(tmpDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bug-1437 — phase.list-plans is wired in gsd-tools', () => {
|
||||||
|
test('command no longer returns Unknown phase subcommand error', () => {
|
||||||
|
touch(phaseDir, '01-01-PLAN.md');
|
||||||
|
const result = runGsdTools(['query', 'phase.list-plans', '1'], tmpDir);
|
||||||
|
// Previously this would fail with "Unknown phase subcommand"
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}\nOutput: ${result.output}`);
|
||||||
|
assert.ok(!result.error || !result.error.includes('Unknown phase subcommand'),
|
||||||
|
`got unexpected error: ${result.error}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns JSON with plan_count and plans array when plans exist', () => {
|
||||||
|
touch(phaseDir, '01-01-PLAN.md', '01-02-PLAN.md');
|
||||||
|
const result = runGsdTools(['query', 'phase.list-plans', '1', '--raw'], tmpDir);
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}\nOutput: ${result.output}`);
|
||||||
|
const data = JSON.parse(result.output);
|
||||||
|
assert.equal(data.plan_count, 2, 'plan_count should be 2');
|
||||||
|
assert.equal(data.has_plans, true, 'has_plans should be true');
|
||||||
|
assert.ok(Array.isArray(data.plans), 'plans should be an array');
|
||||||
|
assert.equal(data.plans.length, 2, 'plans array should have 2 entries');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns plan_count 0 and empty plans array when phase has no plan files', () => {
|
||||||
|
// Phase directory exists but has no *-PLAN.md files
|
||||||
|
touch(phaseDir, 'CONTEXT.md');
|
||||||
|
const result = runGsdTools(['query', 'phase.list-plans', '1', '--raw'], tmpDir);
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}\nOutput: ${result.output}`);
|
||||||
|
const data = JSON.parse(result.output);
|
||||||
|
assert.equal(data.plan_count, 0);
|
||||||
|
assert.equal(data.has_plans, false);
|
||||||
|
assert.deepEqual(data.plans, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns has_plans false when phase number is not found', () => {
|
||||||
|
// Phase 99 does not exist in the fixture
|
||||||
|
const result = runGsdTools(['query', 'phase.list-plans', '99', '--raw'], tmpDir);
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}\nOutput: ${result.output}`);
|
||||||
|
const data = JSON.parse(result.output);
|
||||||
|
assert.equal(data.has_plans, false);
|
||||||
|
assert.equal(data.plan_count, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('plan paths are relative to project root and posix-style', () => {
|
||||||
|
touch(phaseDir, '01-01-PLAN.md');
|
||||||
|
const result = runGsdTools(['query', 'phase.list-plans', '1', '--raw'], tmpDir);
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}\nOutput: ${result.output}`);
|
||||||
|
const data = JSON.parse(result.output);
|
||||||
|
assert.equal(data.plans.length, 1);
|
||||||
|
// Paths must be forward-slash separated (posix) and relative (not absolute)
|
||||||
|
const planPath = data.plans[0];
|
||||||
|
assert.ok(!path.isAbsolute(planPath), `expected relative path, got: ${planPath}`);
|
||||||
|
assert.ok(!planPath.includes('\\'), `expected posix path, got: ${planPath}`);
|
||||||
|
assert.ok(planPath.includes('01-01-PLAN.md'), `expected plan filename in path: ${planPath}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dotted form phase.list-plans (without query prefix) also works', () => {
|
||||||
|
touch(phaseDir, '01-01-PLAN.md');
|
||||||
|
const result = runGsdTools(['phase.list-plans', '1', '--raw'], tmpDir);
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}\nOutput: ${result.output}`);
|
||||||
|
const data = JSON.parse(result.output);
|
||||||
|
assert.equal(data.plan_count, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -5943,3 +5943,959 @@ describe('bug #1229: phase.add must count bullet-only phases to avoid number col
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
// Folded from tests/247-phase-uat-passed.test.cjs — consolidation epic #1969 (B2 #1971)
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const { describe: __foldDescribe } = require('node:test');
|
||||||
|
__foldDescribe("folded:247-phase-uat-passed (consolidation epic #1969 B2 #1971)", () => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration tests for `phase uat-passed <N>` CLI command.
|
||||||
|
* Issue #247 — phase uat-passed predicate
|
||||||
|
*
|
||||||
|
* Tests the full dispatch path: gsd-tools → phase-command-router → phase.cmdPhaseUatPassed
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up a minimal project with a phase directory and ROADMAP so that
|
||||||
|
* findPhaseInternal(cwd, phaseNum) can resolve it.
|
||||||
|
* Returns { tmpDir, phaseDir }.
|
||||||
|
*/
|
||||||
|
function setupProject(phaseSlug = '01-feature') {
|
||||||
|
const tmpDir = createTempProject();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||||
|
[
|
||||||
|
'# Roadmap',
|
||||||
|
'',
|
||||||
|
'- [ ] Phase 1: Feature',
|
||||||
|
'',
|
||||||
|
'### Phase 1: Feature',
|
||||||
|
'**Goal:** Build feature',
|
||||||
|
'**Plans:** 1 plans',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', phaseSlug);
|
||||||
|
fs.mkdirSync(phaseDir, { recursive: true });
|
||||||
|
return { tmpDir, phaseDir };
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeUatFile(phaseDir, filename, content) {
|
||||||
|
fs.writeFileSync(path.join(phaseDir, filename), content, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMtime(filePath, time) {
|
||||||
|
fs.utimesSync(filePath, time, time);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePassingUat() {
|
||||||
|
return [
|
||||||
|
'---',
|
||||||
|
'status: passed',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'# UAT Results',
|
||||||
|
'',
|
||||||
|
'### 1. Login works',
|
||||||
|
'expected: User logs in successfully',
|
||||||
|
'result: passed',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePendingUat() {
|
||||||
|
return [
|
||||||
|
'---',
|
||||||
|
'status: partial',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'# UAT Results',
|
||||||
|
'',
|
||||||
|
'### 1. Login works',
|
||||||
|
'expected: User logs in successfully',
|
||||||
|
'result: passed',
|
||||||
|
'',
|
||||||
|
'### 2. Logout works',
|
||||||
|
'expected: User logs out successfully',
|
||||||
|
'result: pending',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFencedFalsePositiveUat() {
|
||||||
|
// Only "result: passed" lines are inside a fenced block.
|
||||||
|
// The real test has result: pending → should evaluate to passed:false.
|
||||||
|
return [
|
||||||
|
'---',
|
||||||
|
'status: partial',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'# UAT Results',
|
||||||
|
'',
|
||||||
|
'## Example (do not run)',
|
||||||
|
'```',
|
||||||
|
'### 1. Test',
|
||||||
|
'expected: Example',
|
||||||
|
'result: passed',
|
||||||
|
'```',
|
||||||
|
'',
|
||||||
|
'### 1. Real Test',
|
||||||
|
'expected: The thing works',
|
||||||
|
'result: pending',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Basic pass/fail cases ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('phase uat-passed — basic pass/fail', () => {
|
||||||
|
let tmpDir;
|
||||||
|
let phaseDir;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
({ tmpDir, phaseDir } = setupProject('01-feature'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup(tmpDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('passing UAT → passed:true with correct JSON shape', () => {
|
||||||
|
writeUatFile(phaseDir, 'feature-UAT.md', makePassingUat());
|
||||||
|
const result = runGsdTools('phase uat-passed 1', tmpDir);
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}\nOutput: ${result.output}`);
|
||||||
|
|
||||||
|
const out = JSON.parse(result.output);
|
||||||
|
assert.strictEqual(out.passed, true);
|
||||||
|
assert.strictEqual(out.phase, '1');
|
||||||
|
assert.ok(Array.isArray(out.uat_files), 'uat_files must be an array');
|
||||||
|
assert.ok(Array.isArray(out.verification_files), 'verification_files must be an array');
|
||||||
|
assert.ok(Array.isArray(out.checks), 'checks must be an array');
|
||||||
|
assert.ok(Array.isArray(out.blockers), 'blockers must be an array');
|
||||||
|
assert.ok(out.policy && typeof out.policy.require_verification === 'boolean',
|
||||||
|
'policy.require_verification must be a boolean');
|
||||||
|
assert.strictEqual(typeof out.no_uat_artifacts, 'boolean', 'no_uat_artifacts must be a boolean');
|
||||||
|
assert.strictEqual(out.no_uat_artifacts, false, 'no_uat_artifacts must be false when checks exist');
|
||||||
|
assert.strictEqual(out.blockers.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pending UAT → passed:false', () => {
|
||||||
|
writeUatFile(phaseDir, 'feature-UAT.md', makePendingUat());
|
||||||
|
const result = runGsdTools('phase uat-passed 1', tmpDir);
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
|
||||||
|
const out = JSON.parse(result.output);
|
||||||
|
assert.strictEqual(out.passed, false);
|
||||||
|
assert.strictEqual(out.phase, '1');
|
||||||
|
assert.ok(out.blockers.length > 0, 'Should have blockers for pending test');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('false-positive only (fenced block) → passed:false', () => {
|
||||||
|
writeUatFile(phaseDir, 'feature-UAT.md', makeFencedFalsePositiveUat());
|
||||||
|
const result = runGsdTools('phase uat-passed 1', tmpDir);
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
|
||||||
|
const out = JSON.parse(result.output);
|
||||||
|
assert.strictEqual(out.passed, false,
|
||||||
|
'result:passed inside a fenced block must not flip the predicate to passed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no UAT files → passed:false + no_uat_artifacts:true (fail-closed, no vacuous pass)', () => {
|
||||||
|
// Phase directory exists but has no UAT files — fail-closed: absence is NOT a pass
|
||||||
|
const result = runGsdTools('phase uat-passed 1', tmpDir);
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
|
||||||
|
const out = JSON.parse(result.output);
|
||||||
|
assert.strictEqual(out.passed, false,
|
||||||
|
'Phase with no UAT files must NOT vacuously pass — fail-closed predicate');
|
||||||
|
assert.strictEqual(out.no_uat_artifacts, true,
|
||||||
|
'no_uat_artifacts must be true when no UAT items found');
|
||||||
|
assert.deepStrictEqual(out.uat_files, []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── --require-verification flag ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('phase uat-passed — --require-verification flag', () => {
|
||||||
|
let tmpDir;
|
||||||
|
let phaseDir;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
({ tmpDir, phaseDir } = setupProject('01-feature'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup(tmpDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('--require-verification with no verification file → passed:false', () => {
|
||||||
|
writeUatFile(phaseDir, 'feature-UAT.md', makePassingUat());
|
||||||
|
const result = runGsdTools('phase uat-passed 1 --require-verification', tmpDir);
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
|
||||||
|
const out = JSON.parse(result.output);
|
||||||
|
assert.strictEqual(out.passed, false,
|
||||||
|
'require-verification with no verification file should fail');
|
||||||
|
assert.strictEqual(out.policy.require_verification, true);
|
||||||
|
assert.ok(out.blockers.some(b => /verification required/i.test(b)),
|
||||||
|
`Expected verification-required blocker, got: ${JSON.stringify(out.blockers)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('--require-verification with passing verification → passed:true', () => {
|
||||||
|
writeUatFile(phaseDir, 'feature-UAT.md', makePassingUat());
|
||||||
|
writeUatFile(phaseDir, 'feature-VERIFICATION.md', '---\nstatus: passed\n---\n\nVerified OK.');
|
||||||
|
const result = runGsdTools('phase uat-passed 1 --require-verification', tmpDir);
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
|
||||||
|
const out = JSON.parse(result.output);
|
||||||
|
assert.strictEqual(out.passed, true);
|
||||||
|
assert.strictEqual(out.policy.require_verification, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('--require-verification with stale passed verification → passed:false', () => {
|
||||||
|
writeUatFile(phaseDir, 'feature-UAT.md', makePassingUat());
|
||||||
|
const verificationPath = path.join(phaseDir, 'feature-VERIFICATION.md');
|
||||||
|
const summaryPath = path.join(phaseDir, 'feature-SUMMARY.md');
|
||||||
|
writeUatFile(phaseDir, 'feature-VERIFICATION.md', '---\nstatus: passed\n---\n\nVerified OK.');
|
||||||
|
writeUatFile(phaseDir, 'feature-SUMMARY.md', '# Summary\n\nImplementation changed after verification.\n');
|
||||||
|
const now = new Date();
|
||||||
|
setMtime(verificationPath, new Date(now.getTime() - 60_000));
|
||||||
|
setMtime(summaryPath, now);
|
||||||
|
|
||||||
|
const result = runGsdTools('phase uat-passed 1 --require-verification', tmpDir);
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
|
||||||
|
const out = JSON.parse(result.output);
|
||||||
|
assert.strictEqual(out.passed, false);
|
||||||
|
assert.ok(
|
||||||
|
out.blockers.some(b => /verification status=stale/i.test(b)),
|
||||||
|
`Expected stale-verification blocker, got: ${JSON.stringify(out.blockers)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('--require-verification with non-canonical complete verification → passed:false', () => {
|
||||||
|
writeUatFile(phaseDir, 'feature-UAT.md', makePassingUat());
|
||||||
|
writeUatFile(phaseDir, 'feature-VERIFICATION.md', '---\nstatus: complete\n---\n\nLegacy OK.');
|
||||||
|
const result = runGsdTools('phase uat-passed 1 --require-verification', tmpDir);
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
|
||||||
|
const out = JSON.parse(result.output);
|
||||||
|
assert.strictEqual(out.passed, false);
|
||||||
|
assert.ok(out.blockers.some(b => /verification required/i.test(b)),
|
||||||
|
`Expected verification-required blocker, got: ${JSON.stringify(out.blockers)}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Error cases ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('phase uat-passed — error cases', () => {
|
||||||
|
let tmpDir;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = createTempProject();
|
||||||
|
// Write a minimal ROADMAP so phase 1 exists
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||||
|
[
|
||||||
|
'# Roadmap',
|
||||||
|
'',
|
||||||
|
'### Phase 1: Feature',
|
||||||
|
'**Goal:** Build feature',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-feature'), { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup(tmpDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing phase number → error message', () => {
|
||||||
|
const result = runGsdTools('phase uat-passed', tmpDir);
|
||||||
|
assert.ok(!result.success, 'Should fail with no phase number');
|
||||||
|
assert.ok(
|
||||||
|
result.error.includes('phase number required') ||
|
||||||
|
result.error.includes('Available:'),
|
||||||
|
`Expected phase-number-required error, got: ${result.error}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown phase number → error message', () => {
|
||||||
|
const result = runGsdTools('phase uat-passed 99', tmpDir);
|
||||||
|
assert.ok(!result.success, 'Should fail for unknown phase');
|
||||||
|
assert.ok(
|
||||||
|
result.error.includes('not found') || result.error.includes('99'),
|
||||||
|
`Expected not-found error, got: ${result.error}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown flag (typo --require-verifcation) → InvalidArgs error, not silent pass', () => {
|
||||||
|
const result = runGsdTools('phase uat-passed 1 --require-verifcation', tmpDir);
|
||||||
|
assert.ok(!result.success,
|
||||||
|
'Unknown flag must cause an error, not silently pass');
|
||||||
|
assert.ok(
|
||||||
|
result.error.includes('--require-verifcation') ||
|
||||||
|
result.error.includes('does not support') ||
|
||||||
|
result.error.includes('invalid'),
|
||||||
|
`Expected unknown-flag error, got: ${result.error}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
// Folded from tests/bug-2769-requirements-header-variants.test.cjs — consolidation epic #1969 (B2 #1971)
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const { describe: __foldDescribe } = require('node:test');
|
||||||
|
__foldDescribe("folded:bug-2769-requirements-header-variants (consolidation epic #1969 B2 #1971)", () => {
|
||||||
|
/**
|
||||||
|
* Regression tests for issue #2769
|
||||||
|
*
|
||||||
|
* The Requirements header in ROADMAP.md phase blocks renders identically in
|
||||||
|
* markdown for three textually distinct forms:
|
||||||
|
*
|
||||||
|
* **Requirements:** colon INSIDE bold delimiters
|
||||||
|
* **Requirements**: colon OUTSIDE bold delimiters
|
||||||
|
* **Requirements** : space-then-colon outside bold
|
||||||
|
*
|
||||||
|
* Two parsers in the codebase used opposing strict regexes — one only
|
||||||
|
* matched the outside-colon form (init.cjs / init.ts), the other only the
|
||||||
|
* inside-colon form (phase.cjs `cmdPhaseComplete` REQUIREMENTS.md
|
||||||
|
* traceability sweep). Both must accept all three variants so phase
|
||||||
|
* metadata propagation is robust to authoring style.
|
||||||
|
*
|
||||||
|
* Tests for the init query side live in `tests/init.test.cjs` (parameterized
|
||||||
|
* over the three variants). This file exercises the inverse bug in
|
||||||
|
* `phase complete`: the REQUIREMENTS.md checkbox must flip when ROADMAP
|
||||||
|
* uses the outside-colon form, which previously was silently skipped.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||||
|
|
||||||
|
describe('bug #2769: phase complete ticks REQUIREMENTS.md across header variants', () => {
|
||||||
|
let tmpDir;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = createTempProject();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||||
|
['---', 'current_phase: 1', 'status: executing', '---', '# State', ''].join('\n'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup(tmpDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
const headerVariants = [
|
||||||
|
{ name: 'colon inside bold (**Requirements:**)', header: '**Requirements:** REQ-001' },
|
||||||
|
{ name: 'colon outside bold (**Requirements**:)', header: '**Requirements**: REQ-001' },
|
||||||
|
{ name: 'space before colon (**Requirements** :)', header: '**Requirements** : REQ-001' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const variant of headerVariants) {
|
||||||
|
test(`flips REQ-001 checkbox in REQUIREMENTS.md when ROADMAP uses ${variant.name}`, () => {
|
||||||
|
const phasesDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||||||
|
fs.mkdirSync(phasesDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(phasesDir, '01-1-PLAN.md'),
|
||||||
|
['---', 'phase: 1', 'plan: 1', '---', '# Plan 1', ''].join('\n'),
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(phasesDir, '01-1-SUMMARY.md'),
|
||||||
|
['---', 'status: complete', '---', '# Summary', 'Done.'].join('\n'),
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(phasesDir, '01-VERIFICATION.md'),
|
||||||
|
['---', 'status: passed', 'score: "1/1"', '---', '# Verification', 'Passed.'].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const roadmap = [
|
||||||
|
'# Roadmap',
|
||||||
|
'',
|
||||||
|
'### Phase 1: Foundation',
|
||||||
|
'',
|
||||||
|
'**Goal:** Build core',
|
||||||
|
variant.header,
|
||||||
|
'**Plans:** 1 plans',
|
||||||
|
'',
|
||||||
|
'Plans:',
|
||||||
|
'- [x] 01-1-PLAN.md',
|
||||||
|
'',
|
||||||
|
'| Phase | Plans | Status | Completed |',
|
||||||
|
'|-------|-------|--------|-----------|',
|
||||||
|
'| 1. Foundation | 0/1 | Pending | - |',
|
||||||
|
].join('\n');
|
||||||
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
|
||||||
|
|
||||||
|
const requirements = [
|
||||||
|
'# Requirements',
|
||||||
|
'',
|
||||||
|
'## Functional Requirements',
|
||||||
|
'',
|
||||||
|
'- [ ] **REQ-001**: Core data model',
|
||||||
|
'',
|
||||||
|
'## Traceability',
|
||||||
|
'',
|
||||||
|
'| REQ-ID | Phase | Status |',
|
||||||
|
'|--------|-------|--------|',
|
||||||
|
'| REQ-001 | 1 | Pending |',
|
||||||
|
].join('\n');
|
||||||
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), requirements);
|
||||||
|
|
||||||
|
const result = runGsdTools(['phase', 'complete', '1'], tmpDir);
|
||||||
|
assert.ok(result.success, `phase complete failed: ${result.error}`);
|
||||||
|
|
||||||
|
const updated = fs.readFileSync(
|
||||||
|
path.join(tmpDir, '.planning', 'REQUIREMENTS.md'),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
updated,
|
||||||
|
/-\s*\[x\]\s*\*\*REQ-001\*\*/,
|
||||||
|
`REQ-001 checkbox must be flipped to [x] when ROADMAP header is "${variant.header}". Got:\n${updated}`,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
updated,
|
||||||
|
/\|\s*REQ-001\s*\|\s*1\s*\|\s*Complete\s*\|/,
|
||||||
|
`Traceability row for REQ-001 must be marked Complete. Got:\n${updated}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
// Folded from tests/bug-3537-padded-id-against-unpadded-roadmap.test.cjs — consolidation epic #1969 (B2 #1971)
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const { describe: __foldDescribe } = require('node:test');
|
||||||
|
__foldDescribe("folded:bug-3537-padded-id-against-unpadded-roadmap (consolidation epic #1969 B2 #1971)", () => {
|
||||||
|
/**
|
||||||
|
* Regression tests for bug #3537
|
||||||
|
*
|
||||||
|
* Phase state verbs must match a canonical phase id against ROADMAP.md prose
|
||||||
|
* regardless of zero-padding on either side: the skills pass the padded form
|
||||||
|
* (`02.7`) after resolving the phase directory, but human-authored ROADMAP
|
||||||
|
* prose is conventionally un-padded (`### Phase 2.7:`, `- [ ] **Phase 2.7:**`).
|
||||||
|
*
|
||||||
|
* v1.42.1 added `phaseMarkdownRegexSource()` which renders `0*<integer><...>`
|
||||||
|
* — padding-tolerant on both sides — but wired it into only 1 of 8 call sites.
|
||||||
|
* The other 7 used raw `escapeRegex(phaseNum)` or `0*${escapeRegex(...)}`
|
||||||
|
* (tolerated extra padding, not missing), so passing the padded form silently
|
||||||
|
* no-op'd and the verbs returned success while ROADMAP.md was unchanged.
|
||||||
|
*
|
||||||
|
* Parity assertion (per CONTEXT.md DEFECT.GENERATIVE-FIX): for each verb,
|
||||||
|
* running with the padded form must produce the same ROADMAP.md as running
|
||||||
|
* with the un-padded form against an identical fixture. Per-site fixes
|
||||||
|
* without a parity test let the next call-site drift back undetected.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { describe, test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const os = require('node:os');
|
||||||
|
const { execFileSync } = require('node:child_process');
|
||||||
|
|
||||||
|
const { cleanup } = require('./helpers.cjs');
|
||||||
|
|
||||||
|
const gsdTools = path.resolve(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||||
|
|
||||||
|
function run(args, cwd) {
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
stdout: execFileSync('node', [gsdTools, ...args], {
|
||||||
|
cwd,
|
||||||
|
timeout: 15000,
|
||||||
|
encoding: 'utf-8',
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
}),
|
||||||
|
ok: true,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
stdout: (e.stdout && e.stdout.toString()) || '',
|
||||||
|
stderr: (e.stderr && e.stderr.toString()) || '',
|
||||||
|
ok: false,
|
||||||
|
code: e.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a planning fixture with project_code='CK', padded phase directory
|
||||||
|
* (`CK-02.7-meta-lead-ads/`), and un-padded ROADMAP prose (`Phase 2.7`).
|
||||||
|
* This mirrors the reporter's environment in #3537 exactly.
|
||||||
|
*/
|
||||||
|
function setupFixture(tmpDir, opts = {}) {
|
||||||
|
const {
|
||||||
|
projectCode = 'CK',
|
||||||
|
paddedId = '02.7',
|
||||||
|
unpaddedId = '2.7',
|
||||||
|
extraPhases = [],
|
||||||
|
} = opts;
|
||||||
|
|
||||||
|
const planningDir = path.join(tmpDir, '.planning');
|
||||||
|
fs.mkdirSync(planningDir, { recursive: true });
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(planningDir, 'config.json'),
|
||||||
|
JSON.stringify({ project_code: projectCode })
|
||||||
|
);
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(planningDir, 'STATE.md'),
|
||||||
|
`---\ncurrent_phase: ${unpaddedId}\nstatus: executing\n---\n# State\n`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Padded phase directory with one plan + matching summary so the phase
|
||||||
|
// is "complete" for phase-complete and update-plan-progress verbs.
|
||||||
|
const phaseDirName = `${projectCode}-${paddedId}-meta-lead-ads`;
|
||||||
|
const phaseDir = path.join(planningDir, 'phases', phaseDirName);
|
||||||
|
fs.mkdirSync(phaseDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(phaseDir, `${paddedId}-01-PLAN.md`),
|
||||||
|
`---\nphase: ${unpaddedId}\nplan: 1\nwave: 1\n---\n# Plan 1\n`
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(phaseDir, `${paddedId}-01-SUMMARY.md`),
|
||||||
|
'---\nstatus: complete\n---\n# Summary\nDone.'
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(phaseDir, `${paddedId}-VERIFICATION.md`),
|
||||||
|
'---\nstatus: passed\nscore: "1/1"\n---\n# Verification\nPassed.\n'
|
||||||
|
);
|
||||||
|
|
||||||
|
const extra = extraPhases
|
||||||
|
.map((p) => `- [ ] **Phase ${p.id}: ${p.name}**`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
const roadmap = [
|
||||||
|
'# Roadmap',
|
||||||
|
'',
|
||||||
|
'## v1.0 Milestone',
|
||||||
|
'',
|
||||||
|
`- [ ] **Phase ${unpaddedId}: Meta Lead Ads**`,
|
||||||
|
extra,
|
||||||
|
'',
|
||||||
|
'## Progress',
|
||||||
|
'',
|
||||||
|
'| Phase | Plans | Status | Completed |',
|
||||||
|
'|-------|-------|--------|-----------|',
|
||||||
|
`| ${unpaddedId} Meta Lead Ads | 0/1 | Planned | - |`,
|
||||||
|
'',
|
||||||
|
`### Phase ${unpaddedId}: Meta Lead Ads`,
|
||||||
|
'',
|
||||||
|
'**Goal:** ship the thing',
|
||||||
|
'**Plans:** 0 plans',
|
||||||
|
'',
|
||||||
|
'Plans:',
|
||||||
|
`- [ ] ${paddedId}-01-PLAN.md`,
|
||||||
|
'',
|
||||||
|
...extraPhases.flatMap((p) => [
|
||||||
|
`### Phase ${p.id}: ${p.name}`,
|
||||||
|
'',
|
||||||
|
'**Goal:** stub',
|
||||||
|
'**Plans:** 0 plans',
|
||||||
|
'',
|
||||||
|
'Plans:',
|
||||||
|
`- [ ] ${p.id}-01-PLAN.md`,
|
||||||
|
'',
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
.filter((l) => l !== '')
|
||||||
|
.join('\n') + '\n';
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(planningDir, 'ROADMAP.md'), roadmap);
|
||||||
|
|
||||||
|
return {
|
||||||
|
planningDir,
|
||||||
|
roadmapPath: path.join(planningDir, 'ROADMAP.md'),
|
||||||
|
phaseDir,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a verb in two parallel fixtures — one passing the padded form, one
|
||||||
|
* passing the un-padded form — then compare the resulting ROADMAP.md bytes.
|
||||||
|
* Any divergence means the verb's regex did not tolerate padding on at least
|
||||||
|
* one side.
|
||||||
|
*/
|
||||||
|
function expectParity({ verbWithPadded, verbWithUnpadded, fixtureOpts }) {
|
||||||
|
const tmpA = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3537-A-'));
|
||||||
|
const tmpB = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3537-B-'));
|
||||||
|
try {
|
||||||
|
const a = setupFixture(tmpA, fixtureOpts);
|
||||||
|
const b = setupFixture(tmpB, fixtureOpts);
|
||||||
|
|
||||||
|
const ra = verbWithPadded(tmpA);
|
||||||
|
const rb = verbWithUnpadded(tmpB);
|
||||||
|
|
||||||
|
const aRoadmap = fs.readFileSync(a.roadmapPath, 'utf-8');
|
||||||
|
const bRoadmap = fs.readFileSync(b.roadmapPath, 'utf-8');
|
||||||
|
|
||||||
|
return { aRoadmap, bRoadmap, ra, rb };
|
||||||
|
} finally {
|
||||||
|
cleanup(tmpA);
|
||||||
|
cleanup(tmpB);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('bug #3537: phase verbs accept padded ids against un-padded ROADMAP prose', () => {
|
||||||
|
test('phase complete: padded 02.7 and un-padded 2.7 produce identical ROADMAP', () => {
|
||||||
|
const { aRoadmap, bRoadmap } = expectParity({
|
||||||
|
fixtureOpts: {},
|
||||||
|
verbWithPadded: (cwd) => run(['phase', 'complete', '02.7'], cwd),
|
||||||
|
verbWithUnpadded: (cwd) => run(['phase', 'complete', '2.7'], cwd),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
aRoadmap,
|
||||||
|
bRoadmap,
|
||||||
|
'padded `02.7` must mutate ROADMAP identically to un-padded `2.7`'
|
||||||
|
);
|
||||||
|
// And the canonical mutation must have actually happened (otherwise
|
||||||
|
// both forms could be silently no-op'ing and still produce identical
|
||||||
|
// output — a vacuous parity pass).
|
||||||
|
assert.match(
|
||||||
|
aRoadmap,
|
||||||
|
/- \[x\] \*\*Phase 2\.7:/,
|
||||||
|
'overview checkbox should be flipped under both invocations'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('roadmap get-phase: padded 02.7 returns the same section as un-padded 2.7', () => {
|
||||||
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3537-get-'));
|
||||||
|
try {
|
||||||
|
setupFixture(tmp, {});
|
||||||
|
const padded = run(['roadmap', 'get-phase', '02.7', '--raw'], tmp);
|
||||||
|
const unpadded = run(['roadmap', 'get-phase', '2.7', '--raw'], tmp);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
padded.stdout,
|
||||||
|
unpadded.stdout,
|
||||||
|
'padded and un-padded ids must return identical sections'
|
||||||
|
);
|
||||||
|
// Non-vacuous guard: both forms must have actually returned a section
|
||||||
|
// (the bug we're fixing was that the padded form returned an empty
|
||||||
|
// string while reporting success).
|
||||||
|
assert.ok(
|
||||||
|
padded.stdout.trim().length > 0,
|
||||||
|
'verb must return non-empty section under both invocations'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
cleanup(tmp);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('phase next-decimal: padded 02 finds decimals in un-padded ROADMAP', () => {
|
||||||
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3537-nd-'));
|
||||||
|
try {
|
||||||
|
setupFixture(tmp, {
|
||||||
|
paddedId: '02.7',
|
||||||
|
unpaddedId: '2.7',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Padded base `02` must discover the existing decimal `2.7` from the
|
||||||
|
// un-padded heading and propose `2.8` (or higher) as next.
|
||||||
|
const padded = run(['phase', 'next-decimal', '02', '--raw'], tmp);
|
||||||
|
const unpadded = run(['phase', 'next-decimal', '2', '--raw'], tmp);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
padded.stdout,
|
||||||
|
unpadded.stdout,
|
||||||
|
'next-decimal must produce identical JSON for padded and un-padded base'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sanity: the existing 2.7 must be reflected. If the prose-scan regex
|
||||||
|
// silently failed to match `Phase 2.7`, the result would skip 2.7 and
|
||||||
|
// wrongly propose 2.1 as next. `phase next-decimal --raw` emits the
|
||||||
|
// next id as plain text (`02.8`), so trimmed string equality is the
|
||||||
|
// typed assertion shape (no raw-text regex matching — lint policy).
|
||||||
|
const nextDecimalPadded = padded.stdout.trim();
|
||||||
|
assert.notEqual(
|
||||||
|
nextDecimalPadded,
|
||||||
|
'02.1',
|
||||||
|
'must not propose 02.1 when 2.7 already exists in ROADMAP'
|
||||||
|
);
|
||||||
|
assert.notEqual(
|
||||||
|
nextDecimalPadded,
|
||||||
|
'2.1',
|
||||||
|
'must not propose 2.1 when 2.7 already exists in ROADMAP'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
cleanup(tmp);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('phase insert: padded base 02 finds anchor in un-padded ROADMAP', () => {
|
||||||
|
const tmpA = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3537-ins-A-'));
|
||||||
|
const tmpB = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3537-ins-B-'));
|
||||||
|
try {
|
||||||
|
// Use a phase 2 (no decimal) base so insert proposes 2.1.
|
||||||
|
const optsA = {
|
||||||
|
paddedId: '02',
|
||||||
|
unpaddedId: '2',
|
||||||
|
};
|
||||||
|
setupFixture(tmpA, optsA);
|
||||||
|
setupFixture(tmpB, optsA);
|
||||||
|
|
||||||
|
const padded = run(['phase', 'insert', '02', 'urgent extension'], tmpA);
|
||||||
|
const unpadded = run(['phase', 'insert', '2', 'urgent extension'], tmpB);
|
||||||
|
|
||||||
|
// Both invocations should succeed (exit 0) — passing the padded base
|
||||||
|
// against un-padded prose used to error "Phase 02 not found".
|
||||||
|
assert.ok(
|
||||||
|
padded.ok,
|
||||||
|
`padded form must succeed, got code=${padded.code}, stderr=${padded.stderr}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
unpadded.ok,
|
||||||
|
`un-padded form must succeed, got code=${unpadded.code}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const aRoadmap = fs.readFileSync(
|
||||||
|
path.join(tmpA, '.planning', 'ROADMAP.md'),
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
|
||||||
|
// The new header may be rendered as `Phase 02.1` or `Phase 2.1`
|
||||||
|
// (normalizePhaseName pads to 2 digits today; that is pre-existing
|
||||||
|
// behavior, not the subject of #3537). The critical assertion for
|
||||||
|
// this verb is "padded form found the anchor and the insertion
|
||||||
|
// happened" — full byte-parity is gated by an unrelated `Depends on:
|
||||||
|
// Phase ${afterPhase}` echo bug that lies outside #3537's scope.
|
||||||
|
assert.match(
|
||||||
|
aRoadmap,
|
||||||
|
/### Phase 0?2\.1: urgent extension/,
|
||||||
|
'padded form must insert the new decimal phase header'
|
||||||
|
);
|
||||||
|
// Reference `tmpB` to ensure cleanup runs and keep it alive in the
|
||||||
|
// closure — also a smoke-check that the un-padded sibling did not
|
||||||
|
// crash mid-run.
|
||||||
|
assert.ok(fs.existsSync(path.join(tmpB, '.planning', 'ROADMAP.md')));
|
||||||
|
} finally {
|
||||||
|
cleanup(tmpA);
|
||||||
|
cleanup(tmpB);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('roadmap annotate-dependencies: padded 02.7 finds phase section', () => {
|
||||||
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3537-ann-'));
|
||||||
|
try {
|
||||||
|
const { roadmapPath } = setupFixture(tmp, {});
|
||||||
|
const before = fs.readFileSync(roadmapPath, 'utf-8');
|
||||||
|
|
||||||
|
const padded = run(
|
||||||
|
['roadmap', 'annotate-dependencies', '02.7'],
|
||||||
|
tmp
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
padded.ok,
|
||||||
|
`padded form must succeed, got code=${padded.code}, stderr=${padded.stderr}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const after = fs.readFileSync(roadmapPath, 'utf-8');
|
||||||
|
// The annotation may be a no-op if there's only one wave and no
|
||||||
|
// cross-cutting truths, but the verb must have reached the phase
|
||||||
|
// section. Confirm by running parity against un-padded form on a
|
||||||
|
// separate fixture and asserting equality.
|
||||||
|
const tmp2 = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3537-ann2-'));
|
||||||
|
try {
|
||||||
|
const { roadmapPath: rp2 } = setupFixture(tmp2, {});
|
||||||
|
run(['roadmap', 'annotate-dependencies', '2.7'], tmp2);
|
||||||
|
const unpadded = fs.readFileSync(rp2, 'utf-8');
|
||||||
|
assert.equal(
|
||||||
|
after,
|
||||||
|
unpadded,
|
||||||
|
'annotate-dependencies must produce identical output for padded and un-padded ids'
|
||||||
|
);
|
||||||
|
// And the verb must not have silently destroyed the file (sanity).
|
||||||
|
assert.match(after, /### Phase 2\.7:/, 'phase header must survive');
|
||||||
|
// Reference `before` to keep it from being dead-binding-flagged
|
||||||
|
// and to assert the run did not corrupt the rest of the file.
|
||||||
|
assert.ok(before.length > 0);
|
||||||
|
} finally {
|
||||||
|
cleanup(tmp2);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
cleanup(tmp);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('roadmap update-plan-progress: control case — already wired in 1.42.1', () => {
|
||||||
|
// This is the one site already using phaseMarkdownRegexSource. Including
|
||||||
|
// it as a control proves the parity assertion is a meaningful signal
|
||||||
|
// (this test should pass on main, while the others fail).
|
||||||
|
const { aRoadmap, bRoadmap } = expectParity({
|
||||||
|
fixtureOpts: {},
|
||||||
|
verbWithPadded: (cwd) =>
|
||||||
|
run(['roadmap', 'update-plan-progress', '02.7'], cwd),
|
||||||
|
verbWithUnpadded: (cwd) =>
|
||||||
|
run(['roadmap', 'update-plan-progress', '2.7'], cwd),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
aRoadmap,
|
||||||
|
bRoadmap,
|
||||||
|
'control verb must already produce identical output (wired in 1.42.1)'
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
aRoadmap,
|
||||||
|
/- \[x\] \*\*Phase 2\.7:/,
|
||||||
|
'control verb must flip checkbox under both invocations'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
// Folded from tests/bug-2268-parallel-discuss.test.cjs — consolidation epic #1969 (B2 #1971)
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const { describe: __foldDescribe } = require('node:test');
|
||||||
|
__foldDescribe("folded:bug-2268-parallel-discuss (consolidation epic #1969 B2 #1971)", () => {
|
||||||
|
/**
|
||||||
|
* Regression test for bug #2268
|
||||||
|
*
|
||||||
|
* cmdInitProgress used a sliding-window pattern that set is_next_to_discuss
|
||||||
|
* only on the FIRST undiscussed phase. Multiple independent undiscussed phases
|
||||||
|
* could not be discussed in parallel — the manager only ever recommended one
|
||||||
|
* discuss action at a time.
|
||||||
|
*
|
||||||
|
* Fix: mark ALL undiscussed phases as is_next_to_discuss = true so the user
|
||||||
|
* can pick any of them.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||||
|
|
||||||
|
function writeRoadmap(tmpDir, phases) {
|
||||||
|
const sections = phases.map(p => {
|
||||||
|
let section = `### Phase ${p.number}: ${p.name}\n\n**Goal:** Do the thing\n`;
|
||||||
|
return section;
|
||||||
|
}).join('\n');
|
||||||
|
const checklist = phases.map(p => {
|
||||||
|
const mark = p.complete ? 'x' : ' ';
|
||||||
|
return `- [${mark}] **Phase ${p.number}: ${p.name}**`;
|
||||||
|
}).join('\n');
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||||
|
`# Roadmap\n\n## Progress\n\n${checklist}\n\n${sections}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeState(tmpDir) {
|
||||||
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '---\nstatus: active\n---\n# State\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
let tmpDir;
|
||||||
|
|
||||||
|
describe('bug #2268: parallel discuss — all undiscussed phases marked is_next_to_discuss', () => {
|
||||||
|
beforeEach(() => { tmpDir = createTempProject(); });
|
||||||
|
afterEach(() => { cleanup(tmpDir); });
|
||||||
|
|
||||||
|
test('two undiscussed phases: both marked is_next_to_discuss', () => {
|
||||||
|
writeState(tmpDir);
|
||||||
|
writeRoadmap(tmpDir, [
|
||||||
|
{ number: '1', name: 'Foundation' },
|
||||||
|
{ number: '2', name: 'Cloud Deployment' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = runGsdTools('init manager', tmpDir);
|
||||||
|
const output = JSON.parse(result.output);
|
||||||
|
|
||||||
|
assert.strictEqual(output.phases[0].is_next_to_discuss, true, 'phase 1 should be discussable');
|
||||||
|
assert.strictEqual(output.phases[1].is_next_to_discuss, true, 'phase 2 should also be discussable');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two undiscussed phases: both get discuss recommendations', () => {
|
||||||
|
writeState(tmpDir);
|
||||||
|
writeRoadmap(tmpDir, [
|
||||||
|
{ number: '1', name: 'Foundation' },
|
||||||
|
{ number: '2', name: 'Cloud Deployment' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = runGsdTools('init manager', tmpDir);
|
||||||
|
const output = JSON.parse(result.output);
|
||||||
|
|
||||||
|
const discussActions = output.recommended_actions.filter(a => a.action === 'discuss');
|
||||||
|
assert.strictEqual(discussActions.length, 2, 'should recommend discuss for both undiscussed phases');
|
||||||
|
|
||||||
|
const phases = discussActions.map(a => a.phase).sort();
|
||||||
|
assert.deepStrictEqual(phases, ['1', '2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('five undiscussed phases: all five marked is_next_to_discuss', () => {
|
||||||
|
writeState(tmpDir);
|
||||||
|
writeRoadmap(tmpDir, [
|
||||||
|
{ number: '1', name: 'Alpha' },
|
||||||
|
{ number: '2', name: 'Beta' },
|
||||||
|
{ number: '3', name: 'Gamma' },
|
||||||
|
{ number: '4', name: 'Delta' },
|
||||||
|
{ number: '5', name: 'Epsilon' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = runGsdTools('init manager', tmpDir);
|
||||||
|
const output = JSON.parse(result.output);
|
||||||
|
|
||||||
|
for (const phase of output.phases) {
|
||||||
|
assert.strictEqual(phase.is_next_to_discuss, true, `phase ${phase.number} should be discussable`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('discussed phase stays false; undiscussed sibling is true', () => {
|
||||||
|
writeState(tmpDir);
|
||||||
|
writeRoadmap(tmpDir, [
|
||||||
|
{ number: '1', name: 'Foundation' },
|
||||||
|
{ number: '2', name: 'API Layer' },
|
||||||
|
]);
|
||||||
|
// scaffold CONTEXT.md to mark phase 1 as discussed
|
||||||
|
const dir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(dir, '01-CONTEXT.md'), '# Context');
|
||||||
|
|
||||||
|
const result = runGsdTools('init manager', tmpDir);
|
||||||
|
const output = JSON.parse(result.output);
|
||||||
|
|
||||||
|
assert.strictEqual(output.phases[0].is_next_to_discuss, false, 'discussed phase must not be is_next_to_discuss');
|
||||||
|
assert.strictEqual(output.phases[1].is_next_to_discuss, true, 'undiscussed sibling must be is_next_to_discuss');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -83,3 +83,85 @@ describe('phases-command-router', () => {
|
|||||||
assert.equal(message, 'Unknown phases subcommand. Available: list, clear');
|
assert.equal(message, 'Unknown phases subcommand. Available: list, clear');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
// Folded from tests/bug-1826-phases-clear-confirm.test.cjs — consolidation epic #1969 (B2 #1971)
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const { describe: __foldDescribe } = require('node:test');
|
||||||
|
__foldDescribe("folded:bug-1826-phases-clear-confirm (consolidation epic #1969 B2 #1971)", () => {
|
||||||
|
/**
|
||||||
|
* Regression tests for bug #1826
|
||||||
|
*
|
||||||
|
* `phases clear` must require an explicit --confirm flag before deleting any
|
||||||
|
* phase directories. Without it, any accidental or hallucinated invocation
|
||||||
|
* wipes the entire .planning/phases/ tree with no warning.
|
||||||
|
*
|
||||||
|
* Rules:
|
||||||
|
* - Phase dirs present + no --confirm → non-zero exit, clear error message
|
||||||
|
* - Phase dirs present + --confirm → deletes, exits 0, reports count
|
||||||
|
* - No phase dirs + no --confirm → exits 0, cleared=0 (nothing to guard)
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||||
|
|
||||||
|
describe('bug #1826: phases clear --confirm guard', () => {
|
||||||
|
let tmpDir;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = createTempProject();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup(tmpDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('phases clear without --confirm is rejected when phase dirs exist', () => {
|
||||||
|
const phasesDir = path.join(tmpDir, '.planning', 'phases');
|
||||||
|
fs.mkdirSync(path.join(phasesDir, '01-foundation'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(phasesDir, '02-api'), { recursive: true });
|
||||||
|
|
||||||
|
const result = runGsdTools(['phases', 'clear'], tmpDir);
|
||||||
|
|
||||||
|
assert.ok(!result.success, 'should exit non-zero when dirs exist and --confirm absent');
|
||||||
|
assert.ok(
|
||||||
|
result.error.includes('--confirm'),
|
||||||
|
`error message must mention --confirm; got: ${result.error}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dirs must be untouched
|
||||||
|
assert.ok(fs.existsSync(path.join(phasesDir, '01-foundation')), 'dirs must not be deleted');
|
||||||
|
assert.ok(fs.existsSync(path.join(phasesDir, '02-api')), 'dirs must not be deleted');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('phases clear --confirm deletes dirs and reports count', () => {
|
||||||
|
const phasesDir = path.join(tmpDir, '.planning', 'phases');
|
||||||
|
fs.mkdirSync(path.join(phasesDir, '01-foundation'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(phasesDir, '02-api'), { recursive: true });
|
||||||
|
|
||||||
|
const result = runGsdTools(['phases', 'clear', '--confirm'], tmpDir);
|
||||||
|
|
||||||
|
assert.ok(result.success, `should succeed with --confirm: ${result.error}`);
|
||||||
|
const out = JSON.parse(result.output);
|
||||||
|
assert.strictEqual(out.cleared, 2);
|
||||||
|
assert.ok(!fs.existsSync(path.join(phasesDir, '01-foundation')), 'dirs should be removed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('phases clear without --confirm succeeds when no phase dirs exist', () => {
|
||||||
|
// .planning/phases/ exists but is empty — nothing to guard
|
||||||
|
const result = runGsdTools(['phases', 'clear'], tmpDir);
|
||||||
|
|
||||||
|
assert.ok(result.success, `should succeed with empty phases dir: ${result.error}`);
|
||||||
|
const out = JSON.parse(result.output);
|
||||||
|
assert.strictEqual(out.cleared, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -157,3 +157,153 @@ describe('profile-questionnaire command', () => {
|
|||||||
assert.ok(out.questions[0].options, 'each question should have options');
|
assert.ok(out.questions[0].options, 'each question should have options');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
// Folded from tests/bug-866-profile-pipeline-temp-root.test.cjs — consolidation epic #1969 (B2 #1971)
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const { describe: __foldDescribe } = require('node:test');
|
||||||
|
__foldDescribe("folded:bug-866-profile-pipeline-temp-root (consolidation epic #1969 B2 #1971)", () => {
|
||||||
|
'use strict';
|
||||||
|
/**
|
||||||
|
* Regression test for bug #866: profile-pipeline temp output dirs must be
|
||||||
|
* created under GSD_TEMP_DIR (path.join(os.tmpdir(), 'gsd')), not directly
|
||||||
|
* under os.tmpdir() root where reapStaleTempFiles() never scans.
|
||||||
|
*
|
||||||
|
* Hardening (adversarial-review follow-up):
|
||||||
|
* - TMPDIR/TEMP/TMP are redirected to a fixture-scoped directory so the child
|
||||||
|
* process's os.tmpdir() returns an isolated root. This prevents the test from
|
||||||
|
* touching the real shared temp root and keeps it out of the production
|
||||||
|
* reaper's view.
|
||||||
|
* - Both sides of the startsWith assertion are realpath-normalized to kill the
|
||||||
|
* macOS /var ↔ /private/var symlink flakiness.
|
||||||
|
* - An explicit exitCode === 0 assertion is added before JSON.parse so a
|
||||||
|
* non-zero early-exit produces a clear failure rather than a confusing parse
|
||||||
|
* error.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { runGsdTools, createTempDir, cleanup } = require('./helpers.cjs');
|
||||||
|
|
||||||
|
describe('bug-866: profile-pipeline temp dirs under GSD_TEMP_DIR', () => {
|
||||||
|
let tmpDir;
|
||||||
|
let isolatedSysTmp;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = createTempDir('gsd-866-');
|
||||||
|
// Create an isolated os.tmpdir() root inside the fixture so the child
|
||||||
|
// process never writes to the real shared temp dir.
|
||||||
|
isolatedSysTmp = path.join(tmpDir, 'systmp');
|
||||||
|
fs.mkdirSync(isolatedSysTmp, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup(tmpDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper: create a minimal synthetic sessions directory structure
|
||||||
|
function createSessions(root) {
|
||||||
|
const sessionsDir = path.join(root, 'projects');
|
||||||
|
const projectDir = path.join(sessionsDir, 'test-project-866');
|
||||||
|
fs.mkdirSync(projectDir, { recursive: true });
|
||||||
|
const messages = [
|
||||||
|
{ type: 'user', userType: 'external', message: { content: 'fix the login bug' }, timestamp: Date.now() },
|
||||||
|
{ type: 'assistant', message: { content: 'Sure.' }, timestamp: Date.now() },
|
||||||
|
];
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectDir, 'session-001.jsonl'),
|
||||||
|
messages.map(m => JSON.stringify(m)).join('\n')
|
||||||
|
);
|
||||||
|
return sessionsDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('extract-messages output_file is under GSD_TEMP_DIR, not os.tmpdir() root', () => {
|
||||||
|
const sessionsDir = createSessions(tmpDir);
|
||||||
|
|
||||||
|
// Pass the isolated tmp root so the child's os.tmpdir() = isolatedSysTmp.
|
||||||
|
// Belt-and-suspenders: set all three env vars Node checks (TMPDIR=POSIX,
|
||||||
|
// TEMP+TMP=Windows).
|
||||||
|
const result = runGsdTools(
|
||||||
|
['extract-messages', 'test-project-866', '--path', sessionsDir, '--raw'],
|
||||||
|
tmpDir,
|
||||||
|
{ TMPDIR: isolatedSysTmp, TEMP: isolatedSysTmp, TMP: isolatedSysTmp }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Explicit exitCode check first — parse errors are confusing on non-zero exit.
|
||||||
|
assert.strictEqual(result.exitCode, 0, `extract-messages must exit 0; error: ${result.error}`);
|
||||||
|
assert.ok(result.success, `extract-messages failed: ${result.error}`);
|
||||||
|
|
||||||
|
const out = JSON.parse(result.output);
|
||||||
|
assert.ok(out.output_file, 'should have output_file in result');
|
||||||
|
|
||||||
|
const outputFile = out.output_file;
|
||||||
|
|
||||||
|
// The expected GSD_TEMP_DIR from the child's perspective: isolatedSysTmp/gsd
|
||||||
|
const expectedGsdTempDir = path.join(isolatedSysTmp, 'gsd');
|
||||||
|
|
||||||
|
// Normalize both sides via realpath to kill macOS /var↔/private/var symlink
|
||||||
|
// flakiness. Realpath the existing output_file's parent directory (the file
|
||||||
|
// itself may have been cleaned up already, but the dir will exist).
|
||||||
|
const expectedRoot = fs.realpathSync(expectedGsdTempDir);
|
||||||
|
const outputDir = path.dirname(outputFile);
|
||||||
|
// The output dir must exist since the tool just wrote there; realpath it.
|
||||||
|
const actualDir = fs.realpathSync(outputDir);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
actualDir.startsWith(expectedRoot + path.sep) || actualDir === expectedRoot,
|
||||||
|
`output_file "${outputFile}" must be under GSD_TEMP_DIR "${expectedGsdTempDir}" (realpath: ${expectedRoot}); got dir "${actualDir}"`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Must NOT be directly under the isolated systmp root (i.e., no gsd-pipeline-*
|
||||||
|
// at depth 1 of isolatedSysTmp).
|
||||||
|
const rel = path.relative(isolatedSysTmp, outputFile);
|
||||||
|
const depth1Dir = rel.split(path.sep)[0];
|
||||||
|
assert.ok(
|
||||||
|
!depth1Dir.startsWith('gsd-pipeline-'),
|
||||||
|
`output_file must not be in isolatedSysTmp/gsd-pipeline-* but got depth-1 dir: "${depth1Dir}"`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('profile-sample output_file is under GSD_TEMP_DIR, not os.tmpdir() root', () => {
|
||||||
|
const sessionsDir = createSessions(tmpDir);
|
||||||
|
|
||||||
|
const result = runGsdTools(
|
||||||
|
['profile-sample', '--path', sessionsDir, '--raw'],
|
||||||
|
tmpDir,
|
||||||
|
{ TMPDIR: isolatedSysTmp, TEMP: isolatedSysTmp, TMP: isolatedSysTmp }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Explicit exitCode check first.
|
||||||
|
assert.strictEqual(result.exitCode, 0, `profile-sample must exit 0; error: ${result.error}`);
|
||||||
|
assert.ok(result.success, `profile-sample failed: ${result.error}`);
|
||||||
|
|
||||||
|
const out = JSON.parse(result.output);
|
||||||
|
assert.ok(out.output_file, 'should have output_file in result');
|
||||||
|
|
||||||
|
const outputFile = out.output_file;
|
||||||
|
|
||||||
|
const expectedGsdTempDir = path.join(isolatedSysTmp, 'gsd');
|
||||||
|
|
||||||
|
const expectedRoot = fs.realpathSync(expectedGsdTempDir);
|
||||||
|
const outputDir = path.dirname(outputFile);
|
||||||
|
const actualDir = fs.realpathSync(outputDir);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
actualDir.startsWith(expectedRoot + path.sep) || actualDir === expectedRoot,
|
||||||
|
`output_file "${outputFile}" must be under GSD_TEMP_DIR "${expectedGsdTempDir}" (realpath: ${expectedRoot}); got dir "${actualDir}"`
|
||||||
|
);
|
||||||
|
|
||||||
|
const rel = path.relative(isolatedSysTmp, outputFile);
|
||||||
|
const depth1Dir = rel.split(path.sep)[0];
|
||||||
|
assert.ok(
|
||||||
|
!depth1Dir.startsWith('gsd-profile-'),
|
||||||
|
`output_file must not be in isolatedSysTmp/gsd-profile-* but got depth-1 dir: "${depth1Dir}"`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -376,3 +376,90 @@ describe('#1776: prune reads the current phase only from ## Current Position', (
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
// Folded from tests/bug-1760-state-prune-noop-template-field.test.cjs — consolidation epic #1969 (B2 #1971)
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const { describe: __foldDescribe } = require('node:test');
|
||||||
|
__foldDescribe("folded:bug-1760-state-prune-noop-template-field (consolidation epic #1969 B2 #1971)", () => {
|
||||||
|
'use strict';
|
||||||
|
// Regression test for issue #1760 — `state prune` no-ops on template-conformant
|
||||||
|
// STATE.md because it reads `Current Phase` only, never the `Phase: X of Y` line
|
||||||
|
// the canonical template emits.
|
||||||
|
//
|
||||||
|
// ADR-1769 Phase 7 fix: derive the current phase with a `Phase` / `Current Phase`
|
||||||
|
// fallback (mirroring buildStateFrontmatter), so prune engages on template STATE.md.
|
||||||
|
|
||||||
|
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||||
|
|
||||||
|
function writeStateMd(tmpDir, content) {
|
||||||
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('#1760: state prune engages on template-conformant STATE.md (Phase: X of Y)', () => {
|
||||||
|
let tmpDir;
|
||||||
|
beforeEach(() => { tmpDir = createTempProject(); });
|
||||||
|
afterEach(() => { cleanup(tmpDir); });
|
||||||
|
|
||||||
|
test('prune engages when STATE.md uses "Phase: N of M" (no Current Phase field)', () => {
|
||||||
|
// Template-conformant STATE.md: Current Position uses `Phase: 10 of 15`, and
|
||||||
|
// there is NO `**Current Phase:**` body field. Pre-fix this made prune bail
|
||||||
|
// with "Only 0 phases — nothing to prune".
|
||||||
|
writeStateMd(tmpDir, [
|
||||||
|
'# Session State',
|
||||||
|
'',
|
||||||
|
'## Current Position',
|
||||||
|
'',
|
||||||
|
'Phase: 10 of 15',
|
||||||
|
'Plan: 2 of 4',
|
||||||
|
'Status: Executing Phase 10',
|
||||||
|
'',
|
||||||
|
'## Decisions',
|
||||||
|
'',
|
||||||
|
'- [Phase 1]: Old decision',
|
||||||
|
'- [Phase 3]: Older decision',
|
||||||
|
'- [Phase 9]: Recent decision',
|
||||||
|
'',
|
||||||
|
].join('\n'));
|
||||||
|
|
||||||
|
const result = runGsdTools('state prune --keep-recent 3 --dry-run', tmpDir);
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
const out = JSON.parse(result.output);
|
||||||
|
|
||||||
|
// Pre-fix: { pruned: false, reason: 'Only 0 phases — nothing to prune...' }.
|
||||||
|
// Post-fix: prune engages and reports a real cutoff_phase (10 - 3 = 7).
|
||||||
|
assert.strictEqual(out.pruned, false, 'dry-run must report pruned:false');
|
||||||
|
assert.ok(out.reason === undefined || !/Only 0 phases/i.test(String(out.reason)),
|
||||||
|
`prune must not bail with "Only 0 phases" on template STATE.md; got reason=${JSON.stringify(out.reason)}`);
|
||||||
|
assert.strictEqual(out.cutoff_phase, 7,
|
||||||
|
`cutoff_phase must be 7 (current 10 - keep-recent 3); got ${JSON.stringify(out.cutoff_phase)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prune still engages when "Current Phase:" IS present (no regression)', () => {
|
||||||
|
writeStateMd(tmpDir, [
|
||||||
|
'# Session State',
|
||||||
|
'',
|
||||||
|
'**Current Phase:** 10',
|
||||||
|
'',
|
||||||
|
'## Decisions',
|
||||||
|
'',
|
||||||
|
'- [Phase 1]: Old decision',
|
||||||
|
'- [Phase 9]: Recent decision',
|
||||||
|
'',
|
||||||
|
].join('\n'));
|
||||||
|
|
||||||
|
const result = runGsdTools('state prune --keep-recent 3 --dry-run', tmpDir);
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
const out = JSON.parse(result.output);
|
||||||
|
assert.strictEqual(out.cutoff_phase, 7);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
3830
tests/state.test.cjs
3830
tests/state.test.cjs
File diff suppressed because it is too large
Load Diff
@@ -24,3 +24,71 @@ describe('stats — MVP mode summary', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
// Folded from tests/bug-3150-stats-json-decimal-phase-gaps.test.cjs — consolidation epic #1969 (B2 #1971)
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const { describe: __foldDescribe } = require('node:test');
|
||||||
|
__foldDescribe("folded:bug-3150-stats-json-decimal-phase-gaps (consolidation epic #1969 B2 #1971)", () => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { test, describe } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
|
||||||
|
|
||||||
|
describe('bug #3150: stats.json includes contiguous decimal phases when .10 exists', () => {
|
||||||
|
test('stats json preserves 06.7/06.8/06.9 alongside 06.10', () => {
|
||||||
|
const tmpDir = createTempProject('gsd-bug-3150-');
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||||
|
`# Roadmap\n\n### Phase 06.6: P06.6\n**Goal:** G\n\n### Phase 06.7: P06.7\n**Goal:** G\n\n### Phase 06.8: P06.8\n**Goal:** G\n\n### Phase 06.9: P06.9\n**Goal:** G\n\n### Phase 06.10: P06.10\n**Goal:** G\n`
|
||||||
|
);
|
||||||
|
|
||||||
|
const dirs = ['06.6-a', '06.7-b', '06.8-c', '06.9-d', '06.10-e'];
|
||||||
|
for (const dirName of dirs) {
|
||||||
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', dirName);
|
||||||
|
fs.mkdirSync(phaseDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(phaseDir, 'PLAN.md'), '# plan\n');
|
||||||
|
fs.writeFileSync(path.join(phaseDir, 'SUMMARY.md'), '# summary\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = runGsdTools('stats json', tmpDir);
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
|
||||||
|
let output;
|
||||||
|
assert.doesNotThrow(
|
||||||
|
() => {
|
||||||
|
output = JSON.parse(result.output);
|
||||||
|
},
|
||||||
|
`Command output must be valid JSON. Raw output prefix: ${result.output.slice(0, 200)}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
output && typeof output === 'object' && !Array.isArray(output),
|
||||||
|
`Expected object output, got: ${typeof output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
Array.isArray(output.phases),
|
||||||
|
`Expected output.phases array. Raw output prefix: ${result.output.slice(0, 200)}`
|
||||||
|
);
|
||||||
|
const phaseNumbers = output.phases.map((p) => p.number);
|
||||||
|
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
phaseNumbers,
|
||||||
|
['06.6', '06.7', '06.8', '06.9', '06.10'],
|
||||||
|
'stats.json must not skip 06.7/06.8/06.9 when 06.10 exists'
|
||||||
|
);
|
||||||
|
assert.equal(output.phases_total, 5);
|
||||||
|
assert.equal(output.total_plans, 5);
|
||||||
|
assert.equal(output.total_summaries, 5);
|
||||||
|
} finally {
|
||||||
|
cleanup(tmpDir);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -322,3 +322,212 @@ describe('detect-custom-files — update workflow backup detection (#1997)', ()
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
// Folded from tests/bug-2942-detect-custom-skills.test.cjs — consolidation epic #1969 (B2 #1971)
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const { describe: __foldDescribe } = require('node:test');
|
||||||
|
__foldDescribe("folded:bug-2942-detect-custom-skills (consolidation epic #1969 B2 #1971)", () => {
|
||||||
|
/**
|
||||||
|
* GSD Tools Tests — detect-custom-files misses skills/ directory (#2942)
|
||||||
|
*
|
||||||
|
* After v1.39.0 skill consolidation (#2790), skills/ became a GSD-managed root.
|
||||||
|
* GSD_MANAGED_DIRS was missing 'skills', so user-added GSD-prefixed skill
|
||||||
|
* directories like skills/gsd-custom-skill/SKILL.md were never walked and got
|
||||||
|
* silently destroyed during /gsd-update.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { runGsdTools, createTempDir, cleanup } = require('./helpers.cjs');
|
||||||
|
|
||||||
|
function sha256(content) {
|
||||||
|
return crypto.createHash('sha256').update(content).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a fake gsd-file-manifest.json into configDir with the given file entries.
|
||||||
|
* Each entry is also written to disk so the directory structure exists.
|
||||||
|
*/
|
||||||
|
function writeManifest(configDir, files) {
|
||||||
|
const manifest = {
|
||||||
|
version: '1.39.0',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
files: {}
|
||||||
|
};
|
||||||
|
for (const [relPath, content] of Object.entries(files)) {
|
||||||
|
const fullPath = path.join(configDir, relPath);
|
||||||
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||||
|
fs.writeFileSync(fullPath, content);
|
||||||
|
manifest.files[relPath] = sha256(content);
|
||||||
|
}
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(configDir, 'gsd-file-manifest.json'),
|
||||||
|
JSON.stringify(manifest, null, 2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a file inside configDir (creating parent dirs), but do NOT add it to the manifest.
|
||||||
|
*/
|
||||||
|
function writeCustomFile(configDir, relPath, content) {
|
||||||
|
const fullPath = path.join(configDir, relPath);
|
||||||
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||||
|
fs.writeFileSync(fullPath, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('detect-custom-files — skills/ directory missing from GSD_MANAGED_DIRS (#2942)', () => {
|
||||||
|
let tmpDir;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = createTempDir('gsd-2942-skills-');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup(tmpDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 1: detects custom GSD-prefixed skill in skills/gsd-<name>/SKILL.md
|
||||||
|
test('detects custom skill file at skills/gsd-<name>/SKILL.md', () => {
|
||||||
|
writeManifest(tmpDir, {
|
||||||
|
'skills/gsd-planner/SKILL.md': '# GSD Planner Skill\n',
|
||||||
|
});
|
||||||
|
|
||||||
|
// User-added custom GSD-prefixed skill — NOT in manifest
|
||||||
|
writeCustomFile(tmpDir, 'skills/gsd-test-custom/SKILL.md', '# My Custom Skill\n');
|
||||||
|
|
||||||
|
const result = runGsdTools(
|
||||||
|
['detect-custom-files', '--config-dir', tmpDir],
|
||||||
|
tmpDir
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
|
||||||
|
const json = JSON.parse(result.output);
|
||||||
|
assert.ok(Array.isArray(json.custom_files), 'custom_files should be an array');
|
||||||
|
assert.ok(json.custom_count >= 1, `custom_count should be >= 1, got ${json.custom_count}`);
|
||||||
|
assert.ok(
|
||||||
|
json.custom_files.includes('skills/gsd-test-custom/SKILL.md'),
|
||||||
|
`skills/gsd-test-custom/SKILL.md should be in custom_files; got: ${JSON.stringify(json.custom_files)}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not detect non-gsd shared skills preserved by installer (#1325)', () => {
|
||||||
|
writeManifest(tmpDir, {
|
||||||
|
'skills/gsd-planner/SKILL.md': '# GSD Planner Skill\n',
|
||||||
|
});
|
||||||
|
|
||||||
|
writeCustomFile(tmpDir, 'skills/test-custom/SKILL.md', '# My Custom Skill\n');
|
||||||
|
|
||||||
|
const result = runGsdTools(
|
||||||
|
['detect-custom-files', '--config-dir', tmpDir],
|
||||||
|
tmpDir
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
|
||||||
|
const json = JSON.parse(result.output);
|
||||||
|
assert.ok(Array.isArray(json.custom_files), 'custom_files should be an array');
|
||||||
|
assert.ok(
|
||||||
|
!json.custom_files.includes('skills/test-custom/SKILL.md'),
|
||||||
|
`non-gsd shared skill should not be in custom_files; got: ${JSON.stringify(json.custom_files)}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 2: does not flag GSD-owned skills as custom (manifest-tracked path NOT in custom_files)
|
||||||
|
test('does not flag GSD-owned skill as custom when it is tracked in manifest', () => {
|
||||||
|
writeManifest(tmpDir, {
|
||||||
|
'skills/gsd-planner/SKILL.md': '# GSD Planner Skill\n',
|
||||||
|
});
|
||||||
|
|
||||||
|
// No extra files — only the manifest-tracked skill exists
|
||||||
|
|
||||||
|
const result = runGsdTools(
|
||||||
|
['detect-custom-files', '--config-dir', tmpDir],
|
||||||
|
tmpDir
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
|
||||||
|
const json = JSON.parse(result.output);
|
||||||
|
assert.ok(Array.isArray(json.custom_files), 'custom_files should be an array');
|
||||||
|
assert.ok(
|
||||||
|
!json.custom_files.includes('skills/gsd-planner/SKILL.md'),
|
||||||
|
`GSD-owned skill should NOT be in custom_files; got: ${JSON.stringify(json.custom_files)}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 3: regression guard — still detects custom files in gsd-core/workflows/
|
||||||
|
test('regression: still detects custom files in gsd-core/workflows/', () => {
|
||||||
|
writeManifest(tmpDir, {
|
||||||
|
'gsd-core/workflows/plan-phase.md': '# Plan Phase\n',
|
||||||
|
'skills/gsd-planner/SKILL.md': '# GSD Planner Skill\n',
|
||||||
|
});
|
||||||
|
|
||||||
|
writeCustomFile(tmpDir, 'gsd-core/workflows/custom-workflow.md', '# My Custom Workflow\n');
|
||||||
|
|
||||||
|
const result = runGsdTools(
|
||||||
|
['detect-custom-files', '--config-dir', tmpDir],
|
||||||
|
tmpDir
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
|
||||||
|
const json = JSON.parse(result.output);
|
||||||
|
assert.ok(
|
||||||
|
json.custom_files.includes('gsd-core/workflows/custom-workflow.md'),
|
||||||
|
`custom workflow should still be detected; got: ${JSON.stringify(json.custom_files)}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 4: custom_count matches custom_files.length
|
||||||
|
test('custom_count matches custom_files.length when multiple custom gsd-prefixed skills exist', () => {
|
||||||
|
writeManifest(tmpDir, {
|
||||||
|
'skills/gsd-planner/SKILL.md': '# GSD Planner Skill\n',
|
||||||
|
});
|
||||||
|
|
||||||
|
writeCustomFile(tmpDir, 'skills/gsd-test-custom/SKILL.md', '# Custom Skill One\n');
|
||||||
|
writeCustomFile(tmpDir, 'skills/gsd-another-custom/SKILL.md', '# Custom Skill Two\n');
|
||||||
|
|
||||||
|
const result = runGsdTools(
|
||||||
|
['detect-custom-files', '--config-dir', tmpDir],
|
||||||
|
tmpDir
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
|
||||||
|
const json = JSON.parse(result.output);
|
||||||
|
assert.strictEqual(
|
||||||
|
json.custom_count,
|
||||||
|
json.custom_files.length,
|
||||||
|
`custom_count (${json.custom_count}) should equal custom_files.length (${json.custom_files.length})`
|
||||||
|
);
|
||||||
|
assert.strictEqual(json.custom_count, 2, 'should detect exactly 2 custom skill files');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 5: manifest_found: true when manifest is present
|
||||||
|
test('manifest_found is true when manifest is present', () => {
|
||||||
|
writeManifest(tmpDir, {
|
||||||
|
'skills/gsd-planner/SKILL.md': '# GSD Planner Skill\n',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = runGsdTools(
|
||||||
|
['detect-custom-files', '--config-dir', tmpDir],
|
||||||
|
tmpDir
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
|
||||||
|
const json = JSON.parse(result.output);
|
||||||
|
assert.strictEqual(json.manifest_found, true, 'manifest_found should be true');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1011,3 +1011,328 @@ describe('verify key-links command', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
// Folded from tests/bug-967-verify-key-links-strict-paths.test.cjs — consolidation epic #1969 (B2 #1971)
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const { describe: __foldDescribe } = require('node:test');
|
||||||
|
__foldDescribe("folded:bug-967-verify-key-links-strict-paths (consolidation epic #1969 B2 #1971)", () => {
|
||||||
|
/**
|
||||||
|
* Regression test for bug #967: verify key-links reads from:/to: as literal
|
||||||
|
* relative file paths; the reference docs wrongly implied component/endpoint
|
||||||
|
* values were valid. Fix direction: author-strict — docs corrected to match code.
|
||||||
|
*
|
||||||
|
* Contract pinned here:
|
||||||
|
* 1. from: must be a relative file path; pattern: is evaluated against its content.
|
||||||
|
* 2. from: pointing to a non-existent file → verified:false, detail "Source file not found".
|
||||||
|
* 3. docs/reference/plan-md.md reference example uses a file path for to: (NOT /api/feed).
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||||
|
|
||||||
|
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function writePlanWithKeyLinks(tmpDir, keyLinksYaml, opts) {
|
||||||
|
// parseMustHavesBlock expects 4-space indent for block name, 6-space for items
|
||||||
|
const wave = (opts && opts.wave != null) ? opts.wave : 1;
|
||||||
|
const filesModified = (opts && opts.filesModified) ? opts.filesModified : ['src/a.js'];
|
||||||
|
const filesModifiedYaml = filesModified.length === 1
|
||||||
|
? `[${filesModified[0]}]`
|
||||||
|
: `[${filesModified.join(', ')}]`;
|
||||||
|
const content = [
|
||||||
|
'---',
|
||||||
|
'phase: 01-test',
|
||||||
|
'plan: 01',
|
||||||
|
'type: execute',
|
||||||
|
`wave: ${wave}`,
|
||||||
|
'depends_on: []',
|
||||||
|
`files_modified: ${filesModifiedYaml}`,
|
||||||
|
'autonomous: true',
|
||||||
|
'must_haves:',
|
||||||
|
' key_links:',
|
||||||
|
...keyLinksYaml.map(line => ` ${line}`),
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'<tasks>',
|
||||||
|
'<task type="auto">',
|
||||||
|
' <name>Task 1: Do thing</name>',
|
||||||
|
' <files>src/a.js</files>',
|
||||||
|
' <action>Do it</action>',
|
||||||
|
' <verify><automated>echo ok</automated></verify>',
|
||||||
|
' <done>Done</done>',
|
||||||
|
'</task>',
|
||||||
|
'</tasks>',
|
||||||
|
].join('\n');
|
||||||
|
const planPath = path.join(tmpDir, '.planning', 'phases', '01-test', '01-01-PLAN.md');
|
||||||
|
fs.mkdirSync(path.dirname(planPath), { recursive: true });
|
||||||
|
fs.writeFileSync(planPath, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write an additional plan file in the same phase directory with specific
|
||||||
|
* wave + files_modified (no key_links, just declaring future artifacts).
|
||||||
|
*/
|
||||||
|
function writeCompanionPlan(tmpDir, planFileName, wave, filesModified) {
|
||||||
|
const filesModifiedYaml = `[${filesModified.join(', ')}]`;
|
||||||
|
const content = [
|
||||||
|
'---',
|
||||||
|
'phase: 01-test',
|
||||||
|
'plan: 02',
|
||||||
|
'type: execute',
|
||||||
|
`wave: ${wave}`,
|
||||||
|
'depends_on: []',
|
||||||
|
`files_modified: ${filesModifiedYaml}`,
|
||||||
|
'autonomous: true',
|
||||||
|
'must_haves:',
|
||||||
|
' key_links: []',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'<tasks>',
|
||||||
|
'<task type="auto">',
|
||||||
|
' <name>Task 2: Create file</name>',
|
||||||
|
' <files>src/b.js</files>',
|
||||||
|
' <action>Create it</action>',
|
||||||
|
' <verify><automated>echo ok</automated></verify>',
|
||||||
|
' <done>Done</done>',
|
||||||
|
'</task>',
|
||||||
|
'</tasks>',
|
||||||
|
].join('\n');
|
||||||
|
const planPath = path.join(tmpDir, '.planning', 'phases', '01-test', planFileName);
|
||||||
|
fs.writeFileSync(planPath, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('bug-967 verify key-links strict file-path contract', () => {
|
||||||
|
let tmpDir;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = createTempProject();
|
||||||
|
fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup(tmpDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 1. Happy path: from: is a real file path and pattern: matches ──────────
|
||||||
|
test('verified:true when from: is a relative file path and pattern: matches', () => {
|
||||||
|
writePlanWithKeyLinks(tmpDir, [
|
||||||
|
'- from: "src/component.js"',
|
||||||
|
' to: "src/api/feed.js"',
|
||||||
|
' via: "fetch in useEffect"',
|
||||||
|
' pattern: "fetch.*api/feed"',
|
||||||
|
]);
|
||||||
|
// Create the source file containing the pattern
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(tmpDir, 'src', 'component.js'),
|
||||||
|
"fetch('/api/feed').then(r => r.json());\n",
|
||||||
|
);
|
||||||
|
// Create the target file too (not strictly needed for this path, but realistic)
|
||||||
|
fs.mkdirSync(path.join(tmpDir, 'src', 'api'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(tmpDir, 'src', 'api', 'feed.js'), 'module.exports = {};\n');
|
||||||
|
|
||||||
|
const result = runGsdTools(
|
||||||
|
'verify key-links .planning/phases/01-test/01-01-PLAN.md',
|
||||||
|
tmpDir,
|
||||||
|
);
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
|
||||||
|
const output = JSON.parse(result.output);
|
||||||
|
assert.strictEqual(
|
||||||
|
output.all_verified,
|
||||||
|
true,
|
||||||
|
`Expected all_verified:true (file-path from: + matching pattern:). Got: ${JSON.stringify(output)}`,
|
||||||
|
);
|
||||||
|
assert.strictEqual(output.links[0].verified, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 2. Contract: missing source file → verified:false, explicit detail ─────
|
||||||
|
test('verified:false with "Source file not found" detail when from: file does not exist', () => {
|
||||||
|
writePlanWithKeyLinks(tmpDir, [
|
||||||
|
'- from: "src/missing-file.js"',
|
||||||
|
' to: "src/api/feed.js"',
|
||||||
|
' via: "fetch in useEffect"',
|
||||||
|
' pattern: "fetch.*api/feed"',
|
||||||
|
]);
|
||||||
|
// Deliberately do NOT create src/missing-file.js
|
||||||
|
|
||||||
|
const result = runGsdTools(
|
||||||
|
'verify key-links .planning/phases/01-test/01-01-PLAN.md',
|
||||||
|
tmpDir,
|
||||||
|
);
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
|
||||||
|
const output = JSON.parse(result.output);
|
||||||
|
assert.strictEqual(
|
||||||
|
output.links[0].verified,
|
||||||
|
false,
|
||||||
|
`Expected verified:false for absent source file. Got: ${JSON.stringify(output.links[0])}`,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
output.links[0].detail.includes('Source file not found'),
|
||||||
|
`Expected detail to include "Source file not found". Got: "${output.links[0].detail}"`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 3. Regression #1202: missing from: file promised by a same-wave plan → pending:true ──
|
||||||
|
//
|
||||||
|
// A from: file absent on disk but listed in files_modified of another plan at
|
||||||
|
// the same wave must be reported pending:true (not verified:false) and must NOT
|
||||||
|
// count against the all_verified gate.
|
||||||
|
//
|
||||||
|
// This test MUST FAIL before the fix is applied (the gate hard-fails today).
|
||||||
|
test('pending:true and all_verified:true when from: file is promised by a same-wave plan', () => {
|
||||||
|
// Plan under test is wave 2; it references src/future-artifact.js which does not
|
||||||
|
// exist on disk yet.
|
||||||
|
writePlanWithKeyLinks(tmpDir, [
|
||||||
|
'- from: "src/future-artifact.js"',
|
||||||
|
' to: "src/consumer.js"',
|
||||||
|
' via: "requires future-artifact"',
|
||||||
|
' pattern: "future-artifact"',
|
||||||
|
], { wave: 2, filesModified: ['src/consumer.js'] });
|
||||||
|
|
||||||
|
// A companion plan also at wave 2 declares src/future-artifact.js in files_modified
|
||||||
|
writeCompanionPlan(tmpDir, '01-02-PLAN.md', 2, ['src/future-artifact.js']);
|
||||||
|
|
||||||
|
// Do NOT create src/future-artifact.js on disk — it is a planned future file
|
||||||
|
|
||||||
|
const result = runGsdTools(
|
||||||
|
'verify key-links .planning/phases/01-test/01-01-PLAN.md',
|
||||||
|
tmpDir,
|
||||||
|
);
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
|
||||||
|
const out = JSON.parse(result.output);
|
||||||
|
assert.strictEqual(
|
||||||
|
out.links[0].pending,
|
||||||
|
true,
|
||||||
|
`Expected pending:true for a from: file promised by a same-wave plan. Got: ${JSON.stringify(out.links[0])}`,
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
out.all_verified,
|
||||||
|
true,
|
||||||
|
`Expected all_verified:true (pending links should not fail the gate). Got: ${JSON.stringify(out)}`,
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
out.links[0].verified,
|
||||||
|
false,
|
||||||
|
`Expected verified:false (file is not yet verified — just pending). Got: ${JSON.stringify(out.links[0])}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 4. Regression #1202: missing from: file promised by a LATER-wave plan → pending:true ──
|
||||||
|
test('pending:true and all_verified:true when from: file is promised by a later-wave plan', () => {
|
||||||
|
// Plan under test is wave 1; companion plan is wave 3 (later wave promises the file)
|
||||||
|
writePlanWithKeyLinks(tmpDir, [
|
||||||
|
'- from: "src/later-artifact.js"',
|
||||||
|
' to: "src/consumer.js"',
|
||||||
|
' via: "later wave dependency"',
|
||||||
|
], { wave: 1, filesModified: ['src/consumer.js'] });
|
||||||
|
|
||||||
|
writeCompanionPlan(tmpDir, '01-02-PLAN.md', 3, ['src/later-artifact.js']);
|
||||||
|
|
||||||
|
const result = runGsdTools(
|
||||||
|
'verify key-links .planning/phases/01-test/01-01-PLAN.md',
|
||||||
|
tmpDir,
|
||||||
|
);
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
|
||||||
|
const out = JSON.parse(result.output);
|
||||||
|
assert.strictEqual(
|
||||||
|
out.links[0].pending,
|
||||||
|
true,
|
||||||
|
`Expected pending:true for from: file promised by a later-wave plan. Got: ${JSON.stringify(out.links[0])}`,
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
out.all_verified,
|
||||||
|
true,
|
||||||
|
`Expected all_verified:true (pending links not counted against gate). Got: ${JSON.stringify(out)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 5. Regression #1202: missing from: file NOT promised by any plan → hard failure ──
|
||||||
|
//
|
||||||
|
// Absence of from: file with no plan promising it must remain a genuine verified:false failure.
|
||||||
|
test('verified:false and all_verified:false when from: file is absent and not promised by any plan', () => {
|
||||||
|
writePlanWithKeyLinks(tmpDir, [
|
||||||
|
'- from: "src/truly-missing.js"',
|
||||||
|
' to: "src/consumer.js"',
|
||||||
|
' via: "no plan promises this"',
|
||||||
|
], { wave: 1, filesModified: ['src/consumer.js'] });
|
||||||
|
|
||||||
|
// No companion plan that promises src/truly-missing.js
|
||||||
|
|
||||||
|
const result = runGsdTools(
|
||||||
|
'verify key-links .planning/phases/01-test/01-01-PLAN.md',
|
||||||
|
tmpDir,
|
||||||
|
);
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
|
||||||
|
const out = JSON.parse(result.output);
|
||||||
|
assert.strictEqual(
|
||||||
|
out.links[0].verified,
|
||||||
|
false,
|
||||||
|
`Expected verified:false for absent+unpromised from: file. Got: ${JSON.stringify(out.links[0])}`,
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
out.all_verified,
|
||||||
|
false,
|
||||||
|
`Expected all_verified:false (hard failure). Got: ${JSON.stringify(out)}`,
|
||||||
|
);
|
||||||
|
// pending must not be true
|
||||||
|
assert.notStrictEqual(
|
||||||
|
out.links[0].pending,
|
||||||
|
true,
|
||||||
|
`Expected pending not to be true for an absent+unpromised file. Got: ${JSON.stringify(out.links[0])}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 6. Doc-contract guard: reference example must use a file path for to: ──
|
||||||
|
//
|
||||||
|
// The old reference example had to: "/api/feed" (an HTTP endpoint).
|
||||||
|
// After fix #967, to: must be a relative file path like "app/api/feed/route.ts".
|
||||||
|
// This test reads the canonical docs file and asserts the example is consistent
|
||||||
|
// with the strict-path contract.
|
||||||
|
//
|
||||||
|
// allow-test-rule: <runtime-contract-is-the-product> the plan-md.md reference (see #967)
|
||||||
|
// example IS the documented authoring surface for key_links; asserting it uses
|
||||||
|
// a file path (not an endpoint) directly tests the documented contract.
|
||||||
|
test('docs/reference/plan-md.md key_links example uses a relative file path for to:, not an HTTP endpoint', () => {
|
||||||
|
// Locate plan-md.md relative to this test file's repo root
|
||||||
|
const docPath = path.join(__dirname, '..', 'docs', 'reference', 'plan-md.md');
|
||||||
|
assert.ok(fs.existsSync(docPath), `plan-md.md not found at ${docPath}`);
|
||||||
|
const content = fs.readFileSync(docPath, 'utf-8'); // allow-test-rule: <runtime-contract-is-the-product> the plan-md.md reference example IS the documented authoring surface for key_links; asserting it uses a file path (not an endpoint) directly tests the documented contract. (see #967)
|
||||||
|
|
||||||
|
// Find the key_links block in the annotated example (the first YAML frontmatter fence)
|
||||||
|
// The bad old value was: to: "/api/feed"
|
||||||
|
assert.ok(
|
||||||
|
!content.includes('to: "/api/feed"'),
|
||||||
|
'docs/reference/plan-md.md still contains the endpoint-style to: "/api/feed" — ' +
|
||||||
|
'the reference example must use a relative file path (e.g. "app/api/feed/route.ts") ' +
|
||||||
|
'to match the strict file-path contract.',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Also assert the corrected example actually uses a path-like value
|
||||||
|
// (must contain at least one '/' and not start with 'http')
|
||||||
|
const toMatch = content.match(/key_links:[\s\S]*?to:\s*"([^"]+)"/);
|
||||||
|
assert.ok(
|
||||||
|
toMatch,
|
||||||
|
'Could not find a to: field in the key_links example in plan-md.md',
|
||||||
|
);
|
||||||
|
const toValue = toMatch[1];
|
||||||
|
assert.ok(
|
||||||
|
!toValue.startsWith('/api') && !toValue.startsWith('http'),
|
||||||
|
`to: value in the docs example looks like an HTTP endpoint: "${toValue}". ` +
|
||||||
|
'It must be a relative file path.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user