diff --git a/CONTEXT.md b/CONTEXT.md index dbbef0ab8..e8d2ff50b 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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 diff --git a/docs/adr/3524-cjs-sdk-hard-seam.md b/docs/adr/3524-cjs-sdk-hard-seam.md index 5206a736c..b2b2ab80a 100644 --- a/docs/adr/3524-cjs-sdk-hard-seam.md +++ b/docs/adr/3524-cjs-sdk-hard-seam.md @@ -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. diff --git a/scripts/lint-allow-test-rule-refs.allowlist.json b/scripts/lint-allow-test-rule-refs.allowlist.json index fc4b510ee..0a35133b8 100644 --- a/scripts/lint-allow-test-rule-refs.allowlist.json +++ b/scripts/lint-allow-test-rule-refs.allowlist.json @@ -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 :: the plan-md.md reference", - "tests/bug-967-verify-key-links-strict-paths.test.cjs :: 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", diff --git a/scripts/lint-regression-test-names.allowlist.json b/scripts/lint-regression-test-names.allowlist.json index b9b10b975..4390aa567 100644 --- a/scripts/lint-regression-test-names.allowlist.json +++ b/scripts/lint-regression-test-names.allowlist.json @@ -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" ] diff --git a/scripts/lint-test-file-count.allowlist.json b/scripts/lint-test-file-count.allowlist.json index e0b182dd2..c0081d941 100644 --- a/scripts/lint-test-file-count.allowlist.json +++ b/scripts/lint-test-file-count.allowlist.json @@ -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", diff --git a/tests/247-phase-uat-passed.test.cjs b/tests/247-phase-uat-passed.test.cjs deleted file mode 100644 index 357c99b51..000000000 --- a/tests/247-phase-uat-passed.test.cjs +++ /dev/null @@ -1,304 +0,0 @@ -'use strict'; - -/** - * Integration tests for `phase uat-passed ` 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}`, - ); - }); -}); diff --git a/tests/6-validate-cjs-drift-regression.test.cjs b/tests/6-validate-cjs-drift-regression.test.cjs deleted file mode 100644 index ba5679561..000000000 --- a/tests/6-validate-cjs-drift-regression.test.cjs +++ /dev/null @@ -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)}`, - ); - }); -}); diff --git a/tests/audit-command-cutover.test.cjs b/tests/audit-command-cutover.test.cjs index d7c750f3d..64fc338c5 100644 --- a/tests/audit-command-cutover.test.cjs +++ b/tests/audit-command-cutover.test.cjs @@ -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` + ); + } + }); +}); + }); +} diff --git a/tests/bug-167-query-meta-command.test.cjs b/tests/bug-167-query-meta-command.test.cjs deleted file mode 100644 index 62a9c553b..000000000 --- a/tests/bug-167-query-meta-command.test.cjs +++ /dev/null @@ -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' - ); -}); diff --git a/tests/bug-1760-state-prune-noop-template-field.test.cjs b/tests/bug-1760-state-prune-noop-template-field.test.cjs deleted file mode 100644 index 7809a33be..000000000 --- a/tests/bug-1760-state-prune-noop-template-field.test.cjs +++ /dev/null @@ -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); - }); -}); diff --git a/tests/bug-1761-state-sync-wrong-progress.test.cjs b/tests/bug-1761-state-sync-wrong-progress.test.cjs deleted file mode 100644 index c5cb1eec5..000000000 --- a/tests/bug-1761-state-sync-wrong-progress.test.cjs +++ /dev/null @@ -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)', - ); - }); -}); diff --git a/tests/bug-1818-unknown-flags.test.cjs b/tests/bug-1818-unknown-flags.test.cjs deleted file mode 100644 index 580ad9ac1..000000000 --- a/tests/bug-1818-unknown-flags.test.cjs +++ /dev/null @@ -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 --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)); - }); -}); diff --git a/tests/bug-1826-phases-clear-confirm.test.cjs b/tests/bug-1826-phases-clear-confirm.test.cjs deleted file mode 100644 index b45c924ee..000000000 --- a/tests/bug-1826-phases-clear-confirm.test.cjs +++ /dev/null @@ -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); - }); -}); diff --git a/tests/bug-224-pick-stdout-capture.test.cjs b/tests/bug-224-pick-stdout-capture.test.cjs deleted file mode 100644 index 59a718513..000000000 --- a/tests/bug-224-pick-stdout-capture.test.cjs +++ /dev/null @@ -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' - ); - }); -}); diff --git a/tests/bug-2268-parallel-discuss.test.cjs b/tests/bug-2268-parallel-discuss.test.cjs deleted file mode 100644 index 9ee613db9..000000000 --- a/tests/bug-2268-parallel-discuss.test.cjs +++ /dev/null @@ -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'); - }); -}); diff --git a/tests/bug-2601-inherit-model-profile.test.cjs b/tests/bug-2601-inherit-model-profile.test.cjs deleted file mode 100644 index c80234784..000000000 --- a/tests/bug-2601-inherit-model-profile.test.cjs +++ /dev/null @@ -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'); - }); -}); diff --git a/tests/bug-2630-state-frontmatter-milestone-switch.test.cjs b/tests/bug-2630-state-frontmatter-milestone-switch.test.cjs deleted file mode 100644 index 099ecb42d..000000000 --- a/tests/bug-2630-state-frontmatter-milestone-switch.test.cjs +++ /dev/null @@ -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); - }); -}); diff --git a/tests/bug-2659-audit-open-crash.test.cjs b/tests/bug-2659-audit-open-crash.test.cjs deleted file mode 100644 index 9f90ebda8..000000000 --- a/tests/bug-2659-audit-open-crash.test.cjs +++ /dev/null @@ -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' - ); - }); -}); diff --git a/tests/bug-2661-roadmap-sync-parallel.test.cjs b/tests/bug-2661-roadmap-sync-parallel.test.cjs deleted file mode 100644 index 7ea1bdb82..000000000 --- a/tests/bug-2661-roadmap-sync-parallel.test.cjs +++ /dev/null @@ -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( - /([\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' - ); - }); -}); diff --git a/tests/bug-2769-requirements-header-variants.test.cjs b/tests/bug-2769-requirements-header-variants.test.cjs deleted file mode 100644 index 69ed4db90..000000000 --- a/tests/bug-2769-requirements-header-variants.test.cjs +++ /dev/null @@ -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}`, - ); - }); - } -}); diff --git a/tests/bug-2911-audit-open-output-shape.test.cjs b/tests/bug-2911-audit-open-output-shape.test.cjs deleted file mode 100644 index afb87c50b..000000000 --- a/tests/bug-2911-audit-open-output-shape.test.cjs +++ /dev/null @@ -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` - ); - } - }); -}); diff --git a/tests/bug-2942-detect-custom-skills.test.cjs b/tests/bug-2942-detect-custom-skills.test.cjs deleted file mode 100644 index 5affcefe2..000000000 --- a/tests/bug-2942-detect-custom-skills.test.cjs +++ /dev/null @@ -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-/SKILL.md - test('detects custom skill file at skills/gsd-/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'); - }); -}); diff --git a/tests/bug-3086-git-create-tag-config-gate.test.cjs b/tests/bug-3086-git-create-tag-config-gate.test.cjs deleted file mode 100644 index ebfd6a07b..000000000 --- a/tests/bug-3086-git-create-tag-config-gate.test.cjs +++ /dev/null @@ -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 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 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 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 block', - ); - assert.ok( - content.includes(''), - 'complete-milestone.md must have a block in the git_tag step', - ); - }); -}); diff --git a/tests/bug-3150-stats-json-decimal-phase-gaps.test.cjs b/tests/bug-3150-stats-json-decimal-phase-gaps.test.cjs deleted file mode 100644 index 56851750c..000000000 --- a/tests/bug-3150-stats-json-decimal-phase-gaps.test.cjs +++ /dev/null @@ -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); - } - }); -}); diff --git a/tests/bug-3163-codex-agents-md.test.cjs b/tests/bug-3163-codex-agents-md.test.cjs deleted file mode 100644 index b9ea4d153..000000000 --- a/tests/bug-3163-codex-agents-md.test.cjs +++ /dev/null @@ -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'); - }); -}); diff --git a/tests/bug-3197-gsd-tools-config-whitelist.test.cjs b/tests/bug-3197-gsd-tools-config-whitelist.test.cjs deleted file mode 100644 index 2e0eedf03..000000000 --- a/tests/bug-3197-gsd-tools-config-whitelist.test.cjs +++ /dev/null @@ -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)}` - ); - }); -}); diff --git a/tests/bug-3243-dotted-command-form.test.cjs b/tests/bug-3243-dotted-command-form.test.cjs deleted file mode 100644 index 9da9005bf..000000000 --- a/tests/bug-3243-dotted-command-form.test.cjs +++ /dev/null @@ -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 .` calls, - * and any caller that bypasses the SDK (stale npm binary, direct shell-out, - * third-party script) would hit "Unknown command: .". - * - * 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') - ); - }); -}); diff --git a/tests/bug-3257-nested-plans-undercount.test.cjs b/tests/bug-3257-nested-plans-undercount.test.cjs deleted file mode 100644 index ca0154c57..000000000 --- a/tests/bug-3257-nested-plans-undercount.test.cjs +++ /dev/null @@ -1,568 +0,0 @@ -/** - * GSD Tools Tests — Bug #3257 - * - * Regression guard: `buildStateFrontmatter` must count plan/summary files in - * the nested `phases/-/plans/-PLAN--.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}"`); - }); -}); diff --git a/tests/bug-3275-fmstr-non-string-scalars.test.cjs b/tests/bug-3275-fmstr-non-string-scalars.test.cjs deleted file mode 100644 index 0fe89a45c..000000000 --- a/tests/bug-3275-fmstr-non-string-scalars.test.cjs +++ /dev/null @@ -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'); - }); -}); diff --git a/tests/bug-3286-state-write-routing.test.cjs b/tests/bug-3286-state-write-routing.test.cjs deleted file mode 100644 index 6b573ce37..000000000 --- a/tests/bug-3286-state-write-routing.test.cjs +++ /dev/null @@ -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 , routing -// writes to .planning/workstreams//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`, - ); - }); -}); diff --git a/tests/bug-3454-state-dollar-backreference-growth.test.cjs b/tests/bug-3454-state-dollar-backreference-growth.test.cjs deleted file mode 100644 index 25c1c320f..000000000 --- a/tests/bug-3454-state-dollar-backreference-growth.test.cjs +++ /dev/null @@ -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})`); - }); -}); diff --git a/tests/bug-3489-complete-phase-idempotent.test.cjs b/tests/bug-3489-complete-phase-idempotent.test.cjs deleted file mode 100644 index d92fa7c8c..000000000 --- a/tests/bug-3489-complete-phase-idempotent.test.cjs +++ /dev/null @@ -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 ` 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'); - }); -}); diff --git a/tests/bug-3537-padded-id-against-unpadded-roadmap.test.cjs b/tests/bug-3537-padded-id-against-unpadded-roadmap.test.cjs deleted file mode 100644 index 97f4e2e9c..000000000 --- a/tests/bug-3537-padded-id-against-unpadded-roadmap.test.cjs +++ /dev/null @@ -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*<...>` - * — 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' - ); - }); -}); diff --git a/tests/bug-3584-runtime-slash-emitters.test.cjs b/tests/bug-3584-runtime-slash-emitters.test.cjs deleted file mode 100644 index cc11332f8..000000000 --- a/tests/bug-3584-runtime-slash-emitters.test.cjs +++ /dev/null @@ -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:` 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- 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- 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- 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- 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)}`, - ); - } - }); -}); diff --git a/tests/bug-3599-roadmap-get-phase-project-code-prefix.test.cjs b/tests/bug-3599-roadmap-get-phase-project-code-prefix.test.cjs deleted file mode 100644 index 1ae04f2df..000000000 --- a/tests/bug-3599-roadmap-get-phase-project-code-prefix.test.cjs +++ /dev/null @@ -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'); - }); -}); diff --git a/tests/bug-3631-router-raw-flag.test.cjs b/tests/bug-3631-router-raw-flag.test.cjs deleted file mode 100644 index cb9318b79..000000000 --- a/tests/bug-3631-router-raw-flag.test.cjs +++ /dev/null @@ -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 ` must emit the next-decimal token. - * 2. `roadmap get-phase --raw ` 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); - } - }); -}); diff --git a/tests/bug-3691-annotate-deps-plans-block-variants.test.cjs b/tests/bug-3691-annotate-deps-plans-block-variants.test.cjs deleted file mode 100644 index 72a865f11..000000000 --- a/tests/bug-3691-annotate-deps-plans-block-variants.test.cjs +++ /dev/null @@ -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: []', - '---', - '', - `Plan ${plan}`, - '', - ].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:'); - }); -}); diff --git a/tests/bug-397-state-preserve-executor-authored.test.cjs b/tests/bug-397-state-preserve-executor-authored.test.cjs deleted file mode 100644 index 69ec67c6a..000000000 --- a/tests/bug-397-state-preserve-executor-authored.test.cjs +++ /dev/null @@ -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); - } - }); - -}); diff --git a/tests/bug-416-archive-dir-null.test.cjs b/tests/bug-416-archive-dir-null.test.cjs deleted file mode 100644 index 2fadcbb7f..000000000 --- a/tests/bug-416-archive-dir-null.test.cjs +++ /dev/null @@ -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/-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: | 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/-phases/-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)}`, - ); - }); -}); diff --git a/tests/bug-501-flat-phase-details-milestone-leak.test.cjs b/tests/bug-501-flat-phase-details-milestone-leak.test.cjs deleted file mode 100644 index acfb4bc9a..000000000 --- a/tests/bug-501-flat-phase-details-milestone-leak.test.cjs +++ /dev/null @@ -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
- * 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))}` - ); - }); -}); diff --git a/tests/bug-549-total-phases-overcounts-with-phase-section-heading.test.cjs b/tests/bug-549-total-phases-overcounts-with-phase-section-heading.test.cjs deleted file mode 100644 index 41abb1916..000000000 --- a/tests/bug-549-total-phases-overcounts-with-phase-section-heading.test.cjs +++ /dev/null @@ -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); - } - }); -}); diff --git a/tests/bug-557-details-summary-milestone-strip.test.cjs b/tests/bug-557-details-summary-milestone-strip.test.cjs deleted file mode 100644 index 3bc75ee5a..000000000 --- a/tests/bug-557-details-summary-milestone-strip.test.cjs +++ /dev/null @@ -1,248 +0,0 @@ -/** - * Bug #557: Active milestone wrapped in
with version only in - * 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 not found. - * 2. activeMarkerPattern does not include 🔄; only 🚧 is recognised. - * Both misses → stripShippedMilestones() deletes the active
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 tag, and the in-progress marker is 🔄 (not 🚧). -// Shipped milestone v1.2 is correctly collapsed in a
block. -const ROADMAP_DETAILS_SUMMARY = `# Roadmap - -
-✅ v1.2: Foundation (shipped) - -### Phase 1: Bootstrap -**Goal:** Set up infrastructure - -### Phase 2: Core API -**Goal:** Build REST API - -
- -
-🔄 v1.3: Active Sprint - -### Phase 3: Auth -**Goal:** Add authentication - -### Phase 4: Dashboard -**Goal:** Build dashboard UI - -
-`; - -// STATE.md with milestone: v1.3 — version matches the 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 -// ) to confirm the activeMarkerPattern gap is also covered. -const ROADMAP_ROTATE_HEADING = `# Roadmap - -
-✅ v2.0: Shipped (shipped) - -### Phase 1: Old Phase -**Goal:** Done - -
- -## 🔄 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 —
/ active milestone strip', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempProject(); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - // ── Core repro: version only in tag ───────────────────────────── - - test('roadmap.analyze returns correct phase_count when active milestone uses + 🔄', () => { - 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
block because ` + - `the version "v1.3" only appears in a tag and the emoji is 🔄, not 🚧.` - ); - }); - - test('roadmap.analyze does NOT return phase_count: 0 when active milestone is in
', () => { - 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
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
block so the phase section was lost.` - ); - }); - - test('shipped phases in collapsed
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
) must not appear in the analyze output. ` + - `Got phases: ${JSON.stringify(phaseNums)}` - ); - }); - - // ── 🔄 in heading (not ) 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))}` - ); - }); -}); diff --git a/tests/bug-853-bg-dispatch-runtime-gating.test.cjs b/tests/bug-853-bg-dispatch-runtime-gating.test.cjs deleted file mode 100644 index 76ea5cd90..000000000 --- a/tests/bug-853-bg-dispatch-runtime-gating.test.cjs +++ /dev/null @@ -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); - } - }); -}); diff --git a/tests/bug-866-profile-pipeline-temp-root.test.cjs b/tests/bug-866-profile-pipeline-temp-root.test.cjs deleted file mode 100644 index ac3ef5d66..000000000 --- a/tests/bug-866-profile-pipeline-temp-root.test.cjs +++ /dev/null @@ -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}"` - ); - }); -}); diff --git a/tests/bug-905-state-syncstatefrontmatter-preserve-scalars.test.cjs b/tests/bug-905-state-syncstatefrontmatter-preserve-scalars.test.cjs deleted file mode 100644 index 08b6d0bac..000000000 --- a/tests/bug-905-state-syncstatefrontmatter-preserve-scalars.test.cjs +++ /dev/null @@ -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)}`, - ); - }); -}); diff --git a/tests/bug-948-state-noop-write-guard.test.cjs b/tests/bug-948-state-noop-write-guard.test.cjs deleted file mode 100644 index a73ab2d02..000000000 --- a/tests/bug-948-state-noop-write-guard.test.cjs +++ /dev/null @@ -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'); - }); -}); diff --git a/tests/bug-967-verify-key-links-strict-paths.test.cjs b/tests/bug-967-verify-key-links-strict-paths.test.cjs deleted file mode 100644 index cf9798e49..000000000 --- a/tests/bug-967-verify-key-links-strict-paths.test.cjs +++ /dev/null @@ -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}`), - '---', - '', - '', - '', - ' Task 1: Do thing', - ' src/a.js', - ' Do it', - ' echo ok', - ' Done', - '', - '', - ].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: []', - '---', - '', - '', - '', - ' Task 2: Create file', - ' src/b.js', - ' Create it', - ' echo ok', - ' Done', - '', - '', - ].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: 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: 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.', - ); - }); -}); diff --git a/tests/bug-978-milestone-complete-force.test.cjs b/tests/bug-978-milestone-complete-force.test.cjs deleted file mode 100644 index 0ff7d5612..000000000 --- a/tests/bug-978-milestone-complete-force.test.cjs +++ /dev/null @@ -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: ` 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', - ); - }); -}); diff --git a/tests/cjs-command-router-adapter.test.cjs b/tests/cjs-command-router-adapter.test.cjs index 3a59e577f..be92d0564 100644 --- a/tests/cjs-command-router-adapter.test.cjs +++ b/tests/cjs-command-router-adapter.test.cjs @@ -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 .` calls, + * and any caller that bypasses the SDK (stale npm binary, direct shell-out, + * third-party script) would hit "Unknown command: .". + * + * 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 ` must emit the next-decimal token. + * 2. `roadmap get-phase --raw ` 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); + } + }); +}); + }); +} diff --git a/tests/claude-md.test.cjs b/tests/claude-md.test.cjs index 9ecd947fe..c323d0bc5 100644 --- a/tests/claude-md.test.cjs +++ b/tests/claude-md.test.cjs @@ -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'); + }); +}); + }); +} diff --git a/tests/command-routing-hub.test.cjs b/tests/command-routing-hub.test.cjs index 115d900ae..fac68338d 100644 --- a/tests/command-routing-hub.test.cjs +++ b/tests/command-routing-hub.test.cjs @@ -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 --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: "", message: "" } + * + * 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 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 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 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); + } + }); +}); + }); +} diff --git a/tests/config.test.cjs b/tests/config.test.cjs index d75cb3ecc..cd16a1b92 100644 --- a/tests/config.test.cjs +++ b/tests/config.test.cjs @@ -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 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 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 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 block', + ); + assert.ok( + content.includes(''), + 'complete-milestone.md must have a 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' + ); + }); +}); + }); +} diff --git a/tests/enh-2447-roadmap-wave-deps.test.cjs b/tests/enh-2447-roadmap-wave-deps.test.cjs deleted file mode 100644 index e8e725b6a..000000000 --- a/tests/enh-2447-roadmap-wave-deps.test.cjs +++ /dev/null @@ -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: [] ---- - - -Plan ${wave} 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'); - }); -}); diff --git a/tests/feat-3167-ship-pr-body-sections.test.cjs b/tests/feat-3167-ship-pr-body-sections.test.cjs deleted file mode 100644 index 5fe3c29c7..000000000 --- a/tests/feat-3167-ship-pr-body-sections.test.cjs +++ /dev/null @@ -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/); - }); -}); diff --git a/tests/feat-3210-fallow-schema-enum.test.cjs b/tests/feat-3210-fallow-schema-enum.test.cjs deleted file mode 100644 index 8eea70472..000000000 --- a/tests/feat-3210-fallow-schema-enum.test.cjs +++ /dev/null @@ -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' - ); - }); -}); diff --git a/tests/feat-3255-json-errors-mode.test.cjs b/tests/feat-3255-json-errors-mode.test.cjs deleted file mode 100644 index 9be76e791..000000000 --- a/tests/feat-3255-json-errors-mode.test.cjs +++ /dev/null @@ -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: "", message: "" } - * - * 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}`); - }); -}); diff --git a/tests/feat-3310-followup-typed-codes.test.cjs b/tests/feat-3310-followup-typed-codes.test.cjs deleted file mode 100644 index 5f91dba66..000000000 --- a/tests/feat-3310-followup-typed-codes.test.cjs +++ /dev/null @@ -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 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 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 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"`); - } - }); -}); diff --git a/tests/fix-1437-phase-list-plans.test.cjs b/tests/fix-1437-phase-list-plans.test.cjs deleted file mode 100644 index f819606c6..000000000 --- a/tests/fix-1437-phase-list-plans.test.cjs +++ /dev/null @@ -1,125 +0,0 @@ -'use strict'; - -/** - * Regression tests for `gsd-tools query phase.list-plans ` (#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); - }); -}); diff --git a/tests/health-validation.test.cjs b/tests/health-validation.test.cjs index b542bcdeb..0853b5761 100644 --- a/tests/health-validation.test.cjs +++ b/tests/health-validation.test.cjs @@ -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/-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: | 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/-phases/-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)}`, + ); + }); +}); + }); +} diff --git a/tests/init-manager.test.cjs b/tests/init-manager.test.cjs index b2c3c5952..499d2a127 100644 --- a/tests/init-manager.test.cjs +++ b/tests/init-manager.test.cjs @@ -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:` 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- 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- 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- 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- 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)}`, + ); + } + }); +}); + }); +} diff --git a/tests/milestone.test.cjs b/tests/milestone.test.cjs index 5e35f0ea1..dcc01014c 100644 --- a/tests/milestone.test.cjs +++ b/tests/milestone.test.cjs @@ -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: ` 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', + ); + }); +}); + }); +} diff --git a/tests/phase-command-router.test.cjs b/tests/phase-command-router.test.cjs index b7b84332c..c8ebf1ad8 100644 --- a/tests/phase-command-router.test.cjs +++ b/tests/phase-command-router.test.cjs @@ -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 ` (#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); + }); +}); + }); +} diff --git a/tests/phase.test.cjs b/tests/phase.test.cjs index 9680a54fa..2a49af4c6 100644 --- a/tests/phase.test.cjs +++ b/tests/phase.test.cjs @@ -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 ` 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*<...>` + * — 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'); + }); +}); + }); +} diff --git a/tests/phases-command-router.test.cjs b/tests/phases-command-router.test.cjs index dbd157c8a..e0092823b 100644 --- a/tests/phases-command-router.test.cjs +++ b/tests/phases-command-router.test.cjs @@ -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); + }); +}); + }); +} diff --git a/tests/profile-pipeline.test.cjs b/tests/profile-pipeline.test.cjs index 1b576fc95..954d5a26b 100644 --- a/tests/profile-pipeline.test.cjs +++ b/tests/profile-pipeline.test.cjs @@ -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}"` + ); + }); +}); + }); +} diff --git a/tests/roadmap.test.cjs b/tests/roadmap.test.cjs index 29102f5d8..bb5a77d47 100644 --- a/tests/roadmap.test.cjs +++ b/tests/roadmap.test.cjs @@ -1476,3 +1476,1739 @@ describe('regressions: insert missing plan rows (#1163) — adversarial: partial assert.equal(archivedRows.length, 2, `Archived section row count changed:\n${archivedSection}`); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2661-roadmap-sync-parallel.test.cjs — consolidation epic #1969 (B2 #1971) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2661-roadmap-sync-parallel (consolidation epic #1969 B2 #1971)", () => { +// allow-test-rule: source-text-is-the-product (see #2661) +// 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( + /([\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' + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3599-roadmap-get-phase-project-code-prefix.test.cjs — consolidation epic #1969 (B2 #1971) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3599-roadmap-get-phase-project-code-prefix (consolidation epic #1969 B2 #1971)", () => { +/** + * 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'); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-2447-roadmap-wave-deps.test.cjs — consolidation epic #1969 (B2 #1971) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-2447-roadmap-wave-deps (consolidation epic #1969 B2 #1971)", () => { +'use strict'; + +// allow-test-rule: source-text-is-the-product (see #2447) +// 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: [] +--- + + +Plan ${wave} 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'); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-557-details-summary-milestone-strip.test.cjs — consolidation epic #1969 (B2 #1971) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-557-details-summary-milestone-strip (consolidation epic #1969 B2 #1971)", () => { +/** + * Bug #557: Active milestone wrapped in
with version only in + * 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 not found. + * 2. activeMarkerPattern does not include 🔄; only 🚧 is recognised. + * Both misses → stripShippedMilestones() deletes the active
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 tag, and the in-progress marker is 🔄 (not 🚧). +// Shipped milestone v1.2 is correctly collapsed in a
block. +const ROADMAP_DETAILS_SUMMARY = `# Roadmap + +
+✅ v1.2: Foundation (shipped) + +### Phase 1: Bootstrap +**Goal:** Set up infrastructure + +### Phase 2: Core API +**Goal:** Build REST API + +
+ +
+🔄 v1.3: Active Sprint + +### Phase 3: Auth +**Goal:** Add authentication + +### Phase 4: Dashboard +**Goal:** Build dashboard UI + +
+`; + +// STATE.md with milestone: v1.3 — version matches the 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 +// ) to confirm the activeMarkerPattern gap is also covered. +const ROADMAP_ROTATE_HEADING = `# Roadmap + +
+✅ v2.0: Shipped (shipped) + +### Phase 1: Old Phase +**Goal:** Done + +
+ +## 🔄 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 —
/ active milestone strip', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + // ── Core repro: version only in tag ───────────────────────────── + + test('roadmap.analyze returns correct phase_count when active milestone uses + 🔄', () => { + 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
block because ` + + `the version "v1.3" only appears in a tag and the emoji is 🔄, not 🚧.` + ); + }); + + test('roadmap.analyze does NOT return phase_count: 0 when active milestone is in
', () => { + 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
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
block so the phase section was lost.` + ); + }); + + test('shipped phases in collapsed
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
) must not appear in the analyze output. ` + + `Got phases: ${JSON.stringify(phaseNums)}` + ); + }); + + // ── 🔄 in heading (not ) 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))}` + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-549-total-phases-overcounts-with-phase-section-heading.test.cjs — consolidation epic #1969 (B2 #1971) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-549-total-phases-overcounts-with-phase-section-heading (consolidation epic #1969 B2 #1971)", () => { +'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); + } + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3691-annotate-deps-plans-block-variants.test.cjs — consolidation epic #1969 (B2 #1971) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3691-annotate-deps-plans-block-variants (consolidation epic #1969 B2 #1971)", () => { +'use strict'; + +// allow-test-rule: source-text-is-the-product (see #3691) +// 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: []', + '---', + '', + `Plan ${plan}`, + '', + ].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:'); + }); +}); + }); +} diff --git a/tests/state-prune.test.cjs b/tests/state-prune.test.cjs index 211d83add..932cf4d4f 100644 --- a/tests/state-prune.test.cjs +++ b/tests/state-prune.test.cjs @@ -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); + }); +}); + }); +} diff --git a/tests/state.test.cjs b/tests/state.test.cjs index c39fbfd92..c48f92251 100644 --- a/tests/state.test.cjs +++ b/tests/state.test.cjs @@ -5165,3 +5165,3833 @@ describe('T6 section-splice characterization — milestone-switch', () => { } }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-1761-state-sync-wrong-progress.test.cjs — consolidation epic #1969 (B2 #1971) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-1761-state-sync-wrong-progress (consolidation epic #1969 B2 #1971)", () => { +'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)', + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2630-state-frontmatter-milestone-switch.test.cjs — consolidation epic #1969 (B2 #1971) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2630-state-frontmatter-milestone-switch (consolidation epic #1969 B2 #1971)", () => { +/** + * 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); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3286-state-write-routing.test.cjs — consolidation epic #1969 (B2 #1971) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3286-state-write-routing (consolidation epic #1969 B2 #1971)", () => { +'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 , routing +// writes to .planning/workstreams//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`, + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3454-state-dollar-backreference-growth.test.cjs — consolidation epic #1969 (B2 #1971) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3454-state-dollar-backreference-growth (consolidation epic #1969 B2 #1971)", () => { +'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})`); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3489-complete-phase-idempotent.test.cjs — consolidation epic #1969 (B2 #1971) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3489-complete-phase-idempotent (consolidation epic #1969 B2 #1971)", () => { +'use strict'; + +// allow-test-rule: source-text-is-the-product (see #3489) +// 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 ` 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'); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-397-state-preserve-executor-authored.test.cjs — consolidation epic #1969 (B2 #1971) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-397-state-preserve-executor-authored (consolidation epic #1969 B2 #1971)", () => { +'use strict'; +// allow-test-rule: reads runtime STATE.md written to temp dir — behavioral output test, not source-grep (see #397) + +// 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); + } + }); + +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-905-state-syncstatefrontmatter-preserve-scalars.test.cjs — consolidation epic #1969 (B2 #1971) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-905-state-syncstatefrontmatter-preserve-scalars (consolidation epic #1969 B2 #1971)", () => { +'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)}`, + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-948-state-noop-write-guard.test.cjs — consolidation epic #1969 (B2 #1971) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-948-state-noop-write-guard (consolidation epic #1969 B2 #1971)", () => { +'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'); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3275-fmstr-non-string-scalars.test.cjs — consolidation epic #1969 (B2 #1971) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3275-fmstr-non-string-scalars (consolidation epic #1969 B2 #1971)", () => { +/** + * 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'); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3257-nested-plans-undercount.test.cjs — consolidation epic #1969 (B2 #1971) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3257-nested-plans-undercount (consolidation epic #1969 B2 #1971)", () => { +/** + * GSD Tools Tests — Bug #3257 + * + * Regression guard: `buildStateFrontmatter` must count plan/summary files in + * the nested `phases/-/plans/-PLAN--.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}"`); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-501-flat-phase-details-milestone-leak.test.cjs — consolidation epic #1969 (B2 #1971) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-501-flat-phase-details-milestone-leak (consolidation epic #1969 B2 #1971)", () => { +/** + * 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
+ * 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))}` + ); + }); +}); + }); +} diff --git a/tests/stats-mvp-display.test.cjs b/tests/stats-mvp-display.test.cjs index 216338913..662519502 100644 --- a/tests/stats-mvp-display.test.cjs +++ b/tests/stats-mvp-display.test.cjs @@ -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); + } + }); +}); + }); +} diff --git a/tests/update-custom-backup.test.cjs b/tests/update-custom-backup.test.cjs index 1854de2a9..8e05ad079 100644 --- a/tests/update-custom-backup.test.cjs +++ b/tests/update-custom-backup.test.cjs @@ -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-/SKILL.md + test('detects custom skill file at skills/gsd-/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'); + }); +}); + }); +} diff --git a/tests/verify.test.cjs b/tests/verify.test.cjs index be66d54f4..5f4f7a2b9 100644 --- a/tests/verify.test.cjs +++ b/tests/verify.test.cjs @@ -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}`), + '---', + '', + '', + '', + ' Task 1: Do thing', + ' src/a.js', + ' Do it', + ' echo ok', + ' Done', + '', + '', + ].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: []', + '---', + '', + '', + '', + ' Task 2: Create file', + ' src/b.js', + ' Create it', + ' echo ok', + ' Done', + '', + '', + ].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: 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: 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.', + ); + }); +}); + }); +}