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
|
||||
|
||||
- **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)
|
||||
- **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
|
||||
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:
|
||||
- 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.
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
"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-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-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",
|
||||
@@ -41,7 +40,6 @@
|
||||
"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-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-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",
|
||||
@@ -53,7 +51,6 @@
|
||||
"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-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-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",
|
||||
@@ -75,7 +72,6 @@
|
||||
"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-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-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",
|
||||
@@ -91,7 +87,6 @@
|
||||
"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-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-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",
|
||||
@@ -99,7 +94,6 @@
|
||||
"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-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-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",
|
||||
@@ -116,8 +110,6 @@
|
||||
"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-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/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",
|
||||
@@ -162,7 +154,6 @@
|
||||
"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-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-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",
|
||||
@@ -191,7 +182,6 @@
|
||||
"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-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 — code-review.md IS the workflow the orchestrator",
|
||||
"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-1367-claude-local-flat-command-layout.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-17-askuserquestion-option-cap.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-1834-sh-hooks-installed.test.cjs",
|
||||
"bug-1891-file-resolution.test.cjs",
|
||||
@@ -26,8 +21,6 @@
|
||||
"bug-214-phase-researcher-write-truncation-contract.test.cjs",
|
||||
"bug-214-writer-agents-write-truncation-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-2346-agent-read-loop-guards.test.cjs",
|
||||
"bug-2351-intel-kilo-layout.test.cjs",
|
||||
@@ -57,15 +50,10 @@
|
||||
"bug-2557-gemini-local-hook-paths.test.cjs",
|
||||
"bug-2559-stale-search-year.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-2630-state-frontmatter-milestone-switch.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-2661-roadmap-sync-parallel.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-2772-gitmodules-path-intersection.test.cjs",
|
||||
"bug-2784-update-cache-clear-path.test.cjs",
|
||||
@@ -78,10 +66,8 @@
|
||||
"bug-2851-workflow-bare-gsd-tools.test.cjs",
|
||||
"bug-2866-codex-strip-no-trailing-newline.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-2916-handle-branching-default-base.test.cjs",
|
||||
"bug-2942-detect-custom-skills.test.cjs",
|
||||
"bug-2943-config-get-context-window-default.test.cjs",
|
||||
"bug-2948-spike-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-3072-optional-sketch-findings-guard.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-3096-ai-integration-phase-parallel-race.test.cjs",
|
||||
"bug-3097-3099-executor-worktree-path-safety.test.cjs",
|
||||
@@ -110,22 +95,15 @@
|
||||
"bug-3129-validate-commit-git-bypass.test.cjs",
|
||||
"bug-3130-update-npx-robust-invocation.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-3163-codex-agents-md.test.cjs",
|
||||
"bug-3168-task-to-agent-rename.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-3212-execute-phase-stall-safe-resume.test.cjs",
|
||||
"bug-3227-config-set-model-overrides.test.cjs",
|
||||
"bug-3236-capture-seed-one-shot.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-3275-fmstr-non-string-scalars.test.cjs",
|
||||
"bug-3286-state-write-routing.test.cjs",
|
||||
"bug-3290-intel-updater-layout-block.test.cjs",
|
||||
"bug-33-settings-model-profile-adaptive.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-shim-projection-drift-guard.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-3509-path-spaces.test.cjs",
|
||||
"bug-3516-reapply-patches-gsd-update-filter.test.cjs",
|
||||
"bug-3521-quick-cleanup-cwd-pin.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-3542-executor-git-stash-prohibition.test.cjs",
|
||||
"bug-3584-runtime-slash-emitters.test.cjs",
|
||||
"bug-3584-runtime-slash-formatter.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-3608-antigravity-update-runtime-classification.test.cjs",
|
||||
"bug-3610-installer-migration-bundled-hooks-classification.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-3659-applysurface-prune-skill-dirs.test.cjs",
|
||||
"bug-3668-workflow-runtime-resolution.test.cjs",
|
||||
@@ -170,7 +142,6 @@
|
||||
"bug-3683-command-cross-reference-invariant.test.cjs",
|
||||
"bug-3683-workflow-colon-namespace-leak.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-3707-locked-worktree-cleanup.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-3810-no-gsd-sdk-runtime-refs.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-447-gap-analysis-phase-req-ids.test.cjs",
|
||||
"bug-474-clock-seam-date-determinism.test.cjs",
|
||||
"bug-492-effort-manifest-fallback.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-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-580-local-sh-hook-bash-wrapper.test.cjs",
|
||||
"bug-619-codebase-drift-gate-shim.test.cjs",
|
||||
@@ -205,20 +171,14 @@
|
||||
"bug-687-agy-timeout.test.cjs",
|
||||
"bug-730-milestone-phase-details-scope.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-892-validate-checklist-roadmap-phases.test.cjs",
|
||||
"bug-905-state-syncstatefrontmatter-preserve-scalars.test.cjs",
|
||||
"bug-924-claude-flat-skill-layout.test.cjs",
|
||||
"bug-925-context-monitor-hook-event-name.test.cjs",
|
||||
"bug-936-no-nested-spawner-wrap.test.cjs",
|
||||
"bug-941-managed-hooks-registry-manifest.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-967-verify-key-links-strict-paths.test.cjs",
|
||||
"bug-969-test-infra-flake-hardening.test.cjs",
|
||||
"bug-974-graphify-budget-missing-value.test.cjs",
|
||||
"bug-978-milestone-complete-force.test.cjs"
|
||||
"bug-974-graphify-budget-missing-value.test.cjs"
|
||||
]
|
||||
|
||||
@@ -5,9 +5,7 @@
|
||||
"files": [
|
||||
"audit-command-cutover.test.cjs",
|
||||
"audit-fix-command.test.cjs",
|
||||
"bug-2659-audit-open-crash.test.cjs",
|
||||
"bug-2836-audit-open-summary-uat-drift.test.cjs",
|
||||
"bug-2911-audit-open-output-shape.test.cjs"
|
||||
"bug-2836-audit-open-summary-uat-drift.test.cjs"
|
||||
],
|
||||
"issue": "192"
|
||||
},
|
||||
@@ -49,7 +47,6 @@
|
||||
"milestone": {
|
||||
"files": [
|
||||
"bug-730-milestone-phase-details-scope.test.cjs",
|
||||
"bug-978-milestone-complete-force.test.cjs",
|
||||
"milestone-archive.test.cjs",
|
||||
"milestone-helper.test.cjs",
|
||||
"milestone-prefixed-convention.test.cjs",
|
||||
@@ -60,7 +57,6 @@
|
||||
},
|
||||
"phase": {
|
||||
"files": [
|
||||
"fix-1437-phase-list-plans.test.cjs",
|
||||
"bug-214-phase-researcher-write-truncation-contract.test.cjs",
|
||||
"phase-dependency-levels.test.cjs",
|
||||
"phase.test.cjs"
|
||||
@@ -69,10 +65,7 @@
|
||||
},
|
||||
"roadmap": {
|
||||
"files": [
|
||||
"bug-2661-roadmap-sync-parallel.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-phase-fallback.test.cjs",
|
||||
"roadmap.test.cjs"
|
||||
@@ -99,17 +92,9 @@
|
||||
"state": {
|
||||
"files": [
|
||||
"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-2630-state-frontmatter-milestone-switch.test.cjs",
|
||||
"bug-3127-state-begin-phase-idempotent.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-prune.test.cjs",
|
||||
"state-rebuild-cli.test.cjs",
|
||||
@@ -124,7 +109,6 @@
|
||||
"bug-2994-verify-reapply-patches-installed-path.test.cjs",
|
||||
"bug-3381-verify-work-workstream.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-mvp-uat.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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// 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}`);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// 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']);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// 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