From 2c32e8d8902729f44e6e77080f0e20d17aa33ece Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 3 Jul 2026 02:14:17 -0400 Subject: [PATCH] test(#1973): consolidate 48 workflow regression tests into workflow-aspect suites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold 48 issue-named workflow-markdown regression files into the canonical test that owns each workflow aspect (execute-phase-*, plan-phase-*, quick-*, discuss-*, worktree-cleanup, secure-phase, verify, update, settings, etc.), across 32 existing suites. Verbatim block-scoped describe wrappers; 281 subtests conserved 1:1. No new test files. Host-env pre-check: only gsd-settings-advanced spawns CLI and it sets no GSD_WORKSTREAM/GSD_PROJECT value — no leak risk. Regenerates regression-name allowlist (222->181), ratchets file-count allowlist (verify 11->10), makes 30 relocated allow-test-rule exemptions issue-ref-compliant (ADR-456; prunes 30 stale ids). lint:ci green. Part of epic #1969. Closes #1973. Co-Authored-By: Claude Opus 4.8 --- .../lint-allow-test-rule-refs.allowlist.json | 30 - .../lint-regression-test-names.allowlist.json | 41 - scripts/lint-test-file-count.allowlist.json | 1 - tests/ask-user-questions-fallback.test.cjs | 113 +++ ...bug-17-askuserquestion-option-cap.test.cjs | 103 --- ...70-workflow-fallback-install-hint.test.cjs | 43 -- tests/bug-2002-offer-next-context.test.cjs | 86 --- tests/bug-2004-pr-branch-milestone.test.cjs | 88 --- ...search-synthesizer-write-contract.test.cjs | 115 --- ...ug-2384-post-merge-deletion-audit.test.cjs | 79 -- ...-2388-plan-phase-no-branch-rename.test.cjs | 66 -- .../bug-2396-makefile-test-priority.test.cjs | 117 --- .../bug-2399-commit-docs-plan-phase.test.cjs | 77 -- ...2410-stream-checkpoint-heartbeats.test.cjs | 178 ----- ...432-quick-plan-predispatch-commit.test.cjs | 139 ---- tests/bug-2470-update-md-claude-path.test.cjs | 41 - tests/bug-2492-context-coverage-gate.test.cjs | 160 ---- .../bug-2501-resurrection-detection.test.cjs | 62 -- ...ug-2502-insert-phase-state-update.test.cjs | 67 -- tests/bug-2504-uat-foundation-phases.test.cjs | 143 ---- ...ettings-profile-nonclaude-warning.test.cjs | 55 -- ...-2516-inherit-model-execute-phase.test.cjs | 119 --- tests/bug-2523-quick-deferred-items.test.cjs | 56 -- ...9-2550-2552-discuss-phase-context.test.cjs | 105 --- ...ummary-rescue-gitignored-planning.test.cjs | 117 --- ...g-2912-progress-context-authority.test.cjs | 154 ---- tests/bug-2950-stale-command-refs.test.cjs | 144 ---- ...050-update-backup-eacces-nonfatal.test.cjs | 24 - ...72-optional-sketch-findings-guard.test.cjs | 62 -- tests/bug-3083-resume-route-clear.test.cjs | 28 - ...i-integration-phase-parallel-race.test.cjs | 73 -- ...-3120-secure-phase-empty-register.test.cjs | 58 -- ...3130-update-npx-robust-invocation.test.cjs | 51 -- ...bug-3195-quick-resurrection-guard.test.cjs | 96 --- tests/bug-3236-capture-seed-one-shot.test.cjs | 165 ---- .../bug-3320-planner-deep-work-rules.test.cjs | 92 --- .../bug-3381-verify-work-workstream.test.cjs | 80 -- ...reapply-patches-gsd-update-filter.test.cjs | 123 --- tests/bug-3521-quick-cleanup-cwd-pin.test.cjs | 70 -- ...s-model-profile-ui-omits-adaptive.test.cjs | 197 ----- ...-3805-fast-md-log-to-state-schema.test.cjs | 143 ---- ...1-plan-phase-gap-analysis-gsd-run.test.cjs | 84 -- ...7-workflow-no-hardcoded-home-tool.test.cjs | 67 -- tests/bug-687-agy-timeout.test.cjs | 45 -- tests/bug-936-no-nested-spawner-wrap.test.cjs | 207 ----- tests/chain-flag-plan-phase.test.cjs | 76 ++ tests/commit-docs-bypass.test.cjs | 87 +++ tests/discuss-mode.test.cjs | 115 +++ tests/docs-parity-live-registry.test.cjs | 153 ++++ tests/enh-2430-learnings-consumption.test.cjs | 238 ------ tests/enh-2433-todo-phase-linking.test.cjs | 87 --- ...h-773-codex-exec-automation-flags.test.cjs | 140 ---- tests/execute-phase-active-flags.test.cjs | 352 +++++++++ ...cute-phase-step-5-5-deviation-doc.test.cjs | 138 ++++ tests/execute-phase-wave.test.cjs | 447 +++++++++++ tests/feat-22-surfacing-docs.test.cjs | 198 ----- tests/fix-1369-wave-stale-base.test.cjs | 166 ---- ...1520-workflow-mktemp-suffix-final.test.cjs | 78 -- ...ute-phase-human-needed-checkpoint.test.cjs | 128 ---- tests/git-base-branch.test.cjs | 98 +++ tests/gsd-settings-advanced.test.cjs | 272 +++++++ tests/issue-498-identity-drift-lint.test.cjs | 53 ++ tests/issue-815-update-next-channel.test.cjs | 112 +++ tests/new-milestone-clear-phases.test.cjs | 97 +++ tests/next-up-clear-order.test.cjs | 38 + tests/no-hardcoded-home-gsd-tools.test.cjs | 165 ++++ tests/phase.test.cjs | 76 ++ tests/plan-phase-drift-guard.test.cjs | 719 ++++++++++++++++++ tests/plan-phase-ui-redirect.test.cjs | 72 ++ tests/plan-review-convergence.test.cjs | 217 ++++++ tests/planner-decomposition.test.cjs | 102 +++ tests/planner-language-regression.test.cjs | 153 ++++ tests/progress-forensic.test.cjs | 163 ++++ tests/quick-commit-boundary.test.cjs | 215 ++++++ tests/reapply-patches.test.cjs | 132 ++++ tests/research-agent-profiles.test.cjs | 125 +++ ...review-default-reviewers-workflow.test.cjs | 205 +++++ tests/secure-phase.test.cjs | 68 ++ tests/seed-scan-new-milestone.test.cjs | 175 +++++ tests/update-custom-backup.test.cjs | 33 + tests/verify-mvp-uat.test.cjs | 153 ++++ tests/verify-work-auto-transition.test.cjs | 90 +++ tests/worktree-cleanup.test.cjs | 474 ++++++++++++ 83 files changed, 5488 insertions(+), 5086 deletions(-) delete mode 100644 tests/bug-17-askuserquestion-option-cap.test.cjs delete mode 100644 tests/bug-170-workflow-fallback-install-hint.test.cjs delete mode 100644 tests/bug-2002-offer-next-context.test.cjs delete mode 100644 tests/bug-2004-pr-branch-milestone.test.cjs delete mode 100644 tests/bug-222-research-synthesizer-write-contract.test.cjs delete mode 100644 tests/bug-2384-post-merge-deletion-audit.test.cjs delete mode 100644 tests/bug-2388-plan-phase-no-branch-rename.test.cjs delete mode 100644 tests/bug-2396-makefile-test-priority.test.cjs delete mode 100644 tests/bug-2399-commit-docs-plan-phase.test.cjs delete mode 100644 tests/bug-2410-stream-checkpoint-heartbeats.test.cjs delete mode 100644 tests/bug-2432-quick-plan-predispatch-commit.test.cjs delete mode 100644 tests/bug-2470-update-md-claude-path.test.cjs delete mode 100644 tests/bug-2492-context-coverage-gate.test.cjs delete mode 100644 tests/bug-2501-resurrection-detection.test.cjs delete mode 100644 tests/bug-2502-insert-phase-state-update.test.cjs delete mode 100644 tests/bug-2504-uat-foundation-phases.test.cjs delete mode 100644 tests/bug-2506-settings-profile-nonclaude-warning.test.cjs delete mode 100644 tests/bug-2516-inherit-model-execute-phase.test.cjs delete mode 100644 tests/bug-2523-quick-deferred-items.test.cjs delete mode 100644 tests/bug-2549-2550-2552-discuss-phase-context.test.cjs delete mode 100644 tests/bug-2838-summary-rescue-gitignored-planning.test.cjs delete mode 100644 tests/bug-2912-progress-context-authority.test.cjs delete mode 100644 tests/bug-2950-stale-command-refs.test.cjs delete mode 100644 tests/bug-3050-update-backup-eacces-nonfatal.test.cjs delete mode 100644 tests/bug-3072-optional-sketch-findings-guard.test.cjs delete mode 100644 tests/bug-3083-resume-route-clear.test.cjs delete mode 100644 tests/bug-3096-ai-integration-phase-parallel-race.test.cjs delete mode 100644 tests/bug-3120-secure-phase-empty-register.test.cjs delete mode 100644 tests/bug-3130-update-npx-robust-invocation.test.cjs delete mode 100644 tests/bug-3195-quick-resurrection-guard.test.cjs delete mode 100644 tests/bug-3236-capture-seed-one-shot.test.cjs delete mode 100644 tests/bug-3320-planner-deep-work-rules.test.cjs delete mode 100644 tests/bug-3381-verify-work-workstream.test.cjs delete mode 100644 tests/bug-3516-reapply-patches-gsd-update-filter.test.cjs delete mode 100644 tests/bug-3521-quick-cleanup-cwd-pin.test.cjs delete mode 100644 tests/bug-3784-gsd-settings-model-profile-ui-omits-adaptive.test.cjs delete mode 100644 tests/bug-3805-fast-md-log-to-state-schema.test.cjs delete mode 100644 tests/bug-621-plan-phase-gap-analysis-gsd-run.test.cjs delete mode 100644 tests/bug-637-workflow-no-hardcoded-home-tool.test.cjs delete mode 100644 tests/bug-687-agy-timeout.test.cjs delete mode 100644 tests/bug-936-no-nested-spawner-wrap.test.cjs delete mode 100644 tests/enh-2430-learnings-consumption.test.cjs delete mode 100644 tests/enh-2433-todo-phase-linking.test.cjs delete mode 100644 tests/enh-773-codex-exec-automation-flags.test.cjs delete mode 100644 tests/feat-22-surfacing-docs.test.cjs delete mode 100644 tests/fix-1369-wave-stale-base.test.cjs delete mode 100644 tests/fix-1520-workflow-mktemp-suffix-final.test.cjs delete mode 100644 tests/fix-3722-execute-phase-human-needed-checkpoint.test.cjs diff --git a/scripts/lint-allow-test-rule-refs.allowlist.json b/scripts/lint-allow-test-rule-refs.allowlist.json index f04ff49cd..412f95378 100644 --- a/scripts/lint-allow-test-rule-refs.allowlist.json +++ b/scripts/lint-allow-test-rule-refs.allowlist.json @@ -15,27 +15,15 @@ "tests/autonomous-interactive.test.cjs :: source-text-is-the-product", "tests/autonomous-to-flag.test.cjs :: source-text-is-the-product", "tests/bug-131-release-tarball-smoke-explicit-home.test.cjs :: integration-test-input", - "tests/bug-17-askuserquestion-option-cap.test.cjs :: source-text-is-the-product", - "tests/bug-170-workflow-fallback-install-hint.test.cjs :: workflow markdown is shipped product text; this test validates fallback hint literals across all workflow files", "tests/bug-1891-file-resolution.test.cjs :: structural-implementation-guard", "tests/bug-211-launcher-home-fallback.test.cjs :: structural/behavioral regression for the ~/.claude fallback arm in", "tests/bug-2136-sh-hook-version.test.cjs :: structural-regression-guard", "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-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", - "tests/bug-2399-commit-docs-plan-phase.test.cjs :: source-text-is-the-product", - "tests/bug-2410-stream-checkpoint-heartbeats.test.cjs :: source-text-is-the-product", "tests/bug-2419-project-researcher-agent.test.cjs :: source-text-is-the-product", "tests/bug-2421-planner-grep-gate-hygiene.test.cjs :: source-text-is-the-product", - "tests/bug-2470-update-md-claude-path.test.cjs :: source-text-is-the-product", - "tests/bug-2492-context-coverage-gate.test.cjs :: source-text-is-the-product", - "tests/bug-2502-insert-phase-state-update.test.cjs :: source-text-is-the-product", - "tests/bug-2516-inherit-model-execute-phase.test.cjs :: source-text-is-the-product", "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-2686-review-fix-worktree.test.cjs :: source-text-is-the-product", "tests/bug-2772-gitmodules-path-intersection.test.cjs :: source-text-is-the-product", @@ -44,21 +32,14 @@ "tests/bug-2839-review-fix-transactional-cleanup.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-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", "tests/bug-3129-validate-commit-git-bypass.test.cjs :: reads hook shell script to verify delegation pattern — structural contract test, not source-grep", - "tests/bug-3130-update-npx-robust-invocation.test.cjs :: reads product workflow markdown (update.md) to verify structural invocation contract — not a source-grep test", - "tests/bug-3236-capture-seed-one-shot.test.cjs :: source-text-is-the-product — workflow and command .md files", "tests/bug-3290-intel-updater-layout-block.test.cjs :: source-text-is-the-product — agents/gsd-intel-updater.md IS", "tests/bug-33-settings-model-profile-adaptive.test.cjs :: source-text-is-the-product", - "tests/bug-3381-verify-work-workstream.test.cjs :: source-text-is-the-product — verify-work.md is a runtime workflow contract.", "tests/bug-3384-secondary-defects.test.cjs :: source-text-is-the-product", "tests/bug-3430-planner-phase-contract.test.cjs :: source-text-is-the-product", "tests/bug-3446-resume-continue-here-discovery.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", "tests/bug-3523-cjs-loadconfig-branching-strategy-warning.test.cjs :: validates runtime CLI stdout/stderr warning behavior, not source grep", "tests/bug-3542-executor-git-stash-prohibition.test.cjs :: source-text-is-the-product", "tests/bug-3605-stale-research-insert-phase-agent-refs.test.cjs :: source-text-is-the-product", @@ -71,20 +52,14 @@ "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-378-update-check-scoped-name.test.cjs :: structural assertion on hook delegation; the behavior being", - "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-444-resolver-local-claude-install.test.cjs :: structural/behavioral regression for the repo-local .claude/ install", "tests/bug-571-doc-writer-fix-mode-edit-only.test.cjs :: source-text-is-the-product", "tests/bug-619-codebase-drift-gate-shim.test.cjs :: source-text-is-the-product", - "tests/bug-621-plan-phase-gap-analysis-gsd-run.test.cjs :: source-text-is-the-product", "tests/bug-622-graphify-optional-graph-html.test.cjs :: source-text-is-the-product", "tests/bug-630-wave-cleanup-orchestrator-root.test.cjs :: source-text-is-the-product", - "tests/bug-637-workflow-no-hardcoded-home-tool.test.cjs :: source-text-is-the-product", "tests/bug-685-windowshide-spawn.test.cjs :: source-text-is-the-product", - "tests/bug-687-agy-timeout.test.cjs :: source-text-is-the-product", "tests/bug-891-non-claude-runtime-home-fallback.test.cjs :: structural/behavioral regression for non-Claude runtime-home", - "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-patterns-reference.test.cjs :: source-text-is-the-product", "tests/chain-flag-plan-phase.test.cjs :: source-text-is-the-product", @@ -125,8 +100,6 @@ "tests/edge-probe-spec-phase-contract.test.cjs :: runtime-contract-is-the-product — spec-phase.md Step 5.5 is the deployed workflow runtime contract under assertion", "tests/edit-phase.test.cjs :: source-text-is-the-product", "tests/enh-2380-sync-skills.test.cjs :: source-text-is-the-product", - "tests/enh-2430-learnings-consumption.test.cjs :: source-text-is-the-product", - "tests/enh-2433-todo-phase-linking.test.cjs :: source-text-is-the-product", "tests/enh-2500-codebase-mapper-arch-rich-format.test.cjs :: source-text-is-the-product", "tests/enh-2789-description-budget.test.cjs :: source-text-is-the-product", "tests/enh-2790-skill-consolidation.test.cjs :: source-text-is-the-product", @@ -136,7 +109,6 @@ "tests/enh-770-claude-hook-events.test.cjs :: runtime-contract-is-the-product — the hookEventName is", "tests/enh-770-claude-hook-events.test.cjs :: runtime-contract-is-the-product — the stamp template token", "tests/enh-770-claude-hook-events.test.cjs :: runtime-contract-is-the-product — the stdin-read and", - "tests/enh-773-codex-exec-automation-flags.test.cjs :: source-text-is-the-product", "tests/eslint-rules.test.cjs :: must still error", "tests/eslint-rules.test.cjs :: pending migration", "tests/eslint-rules.test.cjs :: source-text-is-the-product", @@ -149,10 +121,8 @@ "tests/execute-phase-worktree-artifacts.test.cjs :: source-text-is-the-product", "tests/explore-command.test.cjs :: source-text-is-the-product", "tests/extract-learnings.test.cjs :: source-text-is-the-product", - "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/fix-3722-execute-phase-human-needed-checkpoint.test.cjs :: source-text-is-the-product", "tests/forensics.test.cjs :: source-text-is-the-product", "tests/frontmatter-cli.test.cjs :: source-text-is-the-product", "tests/gates-taxonomy.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 930490711..19487ef53 100644 --- a/scripts/lint-regression-test-names.allowlist.json +++ b/scripts/lint-regression-test-names.allowlist.json @@ -1,42 +1,22 @@ [ "bug-131-release-tarball-smoke-explicit-home.test.cjs", "bug-1367-claude-local-flat-command-layout.test.cjs", - "bug-17-askuserquestion-option-cap.test.cjs", - "bug-170-workflow-fallback-install-hint.test.cjs", "bug-1834-sh-hooks-installed.test.cjs", "bug-1891-file-resolution.test.cjs", "bug-1974-context-exhaustion-record.test.cjs", - "bug-2002-offer-next-context.test.cjs", - "bug-2004-pr-branch-milestone.test.cjs", "bug-21-state-md-template-frontmatter.test.cjs", "bug-211-launcher-home-fallback.test.cjs", "bug-2136-sh-hook-version.test.cjs", "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-2344-read-guard-claudecode-env.test.cjs", "bug-2346-agent-read-loop-guards.test.cjs", "bug-2351-intel-kilo-layout.test.cjs", - "bug-2384-post-merge-deletion-audit.test.cjs", - "bug-2388-plan-phase-no-branch-rename.test.cjs", - "bug-2396-makefile-test-priority.test.cjs", - "bug-2399-commit-docs-plan-phase.test.cjs", - "bug-2410-stream-checkpoint-heartbeats.test.cjs", "bug-2419-project-researcher-agent.test.cjs", "bug-2421-planner-grep-gate-hygiene.test.cjs", - "bug-2432-quick-plan-predispatch-commit.test.cjs", "bug-2451-context-monitor-over-report.test.cjs", - "bug-2470-update-md-claude-path.test.cjs", - "bug-2492-context-coverage-gate.test.cjs", - "bug-2501-resurrection-detection.test.cjs", - "bug-2502-insert-phase-state-update.test.cjs", - "bug-2504-uat-foundation-phases.test.cjs", - "bug-2506-settings-profile-nonclaude-warning.test.cjs", - "bug-2516-inherit-model-execute-phase.test.cjs", "bug-2520-read-guard-hook-subprocess-env.test.cjs", - "bug-2523-quick-deferred-items.test.cjs", "bug-2543-gsd-slash-namespace.test.cjs", - "bug-2549-2550-2552-discuss-phase-context.test.cjs", "bug-2559-stale-search-year.test.cjs", "bug-260-worktree-path-guard.test.cjs", "bug-261-worktree-force-add-guard.test.cjs", @@ -44,13 +24,10 @@ "bug-2772-gitmodules-path-intersection.test.cjs", "bug-2784-update-cache-clear-path.test.cjs", "bug-2808-skill-hyphen-name.test.cjs", - "bug-2838-summary-rescue-gitignored-planning.test.cjs", "bug-2839-review-fix-transactional-cleanup.test.cjs", "bug-2866-codex-strip-no-trailing-newline.test.cjs", "bug-2876-skill-frontmatter-quote.test.cjs", - "bug-2912-progress-context-authority.test.cjs", "bug-2916-handle-branching-default-base.test.cjs", - "bug-2950-stale-command-refs.test.cjs", "bug-2969-verify-reapply-patches.test.cjs", "bug-2973-profile-user-skills-path.test.cjs", "bug-2990-code-fixer-worktree-branch.test.cjs", @@ -58,24 +35,14 @@ "bug-2994-verify-reapply-patches-installed-path.test.cjs", "bug-2995-post-install-script-paths.test.cjs", "bug-3019-help-passthrough.test.cjs", - "bug-3050-update-backup-eacces-nonfatal.test.cjs", "bug-3054-stale-gsd-next-references.test.cjs", - "bug-3072-optional-sketch-findings-guard.test.cjs", - "bug-3083-resume-route-clear.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", - "bug-3120-secure-phase-empty-register.test.cjs", "bug-3129-validate-commit-git-bypass.test.cjs", - "bug-3130-update-npx-robust-invocation.test.cjs", - "bug-3195-quick-resurrection-guard.test.cjs", - "bug-3236-capture-seed-one-shot.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", "bug-3321-verifier-runs-probes.test.cjs", "bug-3357-codex-legacy-hooks-json-migration.test.cjs", - "bug-3381-verify-work-workstream.test.cjs", "bug-3384-secondary-defects.test.cjs", "bug-3430-planner-phase-contract.test.cjs", "bug-3442-codex-legacy-hooks-json-migration.test.cjs", @@ -83,8 +50,6 @@ "bug-3446-resume-continue-here-discovery.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-3541-installer-migration-prompt-user-resolution.test.cjs", "bug-3542-executor-git-stash-prohibition.test.cjs", @@ -102,22 +67,16 @@ "bug-3689-resume-glob-nomatch.test.cjs", "bug-3735-profiles-core-includes-surface.test.cjs", "bug-378-update-check-scoped-name.test.cjs", - "bug-3784-gsd-settings-model-profile-ui-omits-adaptive.test.cjs", - "bug-3805-fast-md-log-to-state-schema.test.cjs", "bug-3810-no-gsd-sdk-runtime-refs.test.cjs", "bug-444-resolver-local-claude-install.test.cjs", "bug-571-doc-writer-fix-mode-edit-only.test.cjs", "bug-619-codebase-drift-gate-shim.test.cjs", - "bug-621-plan-phase-gap-analysis-gsd-run.test.cjs", "bug-622-graphify-optional-graph-html.test.cjs", "bug-630-wave-cleanup-orchestrator-root.test.cjs", - "bug-637-workflow-no-hardcoded-home-tool.test.cjs", "bug-641-files-from-suite-token.test.cjs", "bug-685-windowshide-spawn.test.cjs", - "bug-687-agy-timeout.test.cjs", "bug-891-non-claude-runtime-home-fallback.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-969-test-infra-flake-hardening.test.cjs" diff --git a/scripts/lint-test-file-count.allowlist.json b/scripts/lint-test-file-count.allowlist.json index 1b5af9578..99cb231d2 100644 --- a/scripts/lint-test-file-count.allowlist.json +++ b/scripts/lint-test-file-count.allowlist.json @@ -88,7 +88,6 @@ "files": [ "bug-2969-verify-reapply-patches.test.cjs", "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", "verify-health.test.cjs", "verify-mvp-uat.test.cjs", diff --git a/tests/ask-user-questions-fallback.test.cjs b/tests/ask-user-questions-fallback.test.cjs index 61c228c33..f115841f5 100644 --- a/tests/ask-user-questions-fallback.test.cjs +++ b/tests/ask-user-questions-fallback.test.cjs @@ -86,3 +86,116 @@ describe('AskUserQuestion text-mode fallback (#2012)', () => { ); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-17-askuserquestion-option-cap.test.cjs — consolidation epic #1969 (B4 #1973) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-17-askuserquestion-option-cap (consolidation epic #1969 B4 #1973)", () => { +'use strict'; + +// allow-test-rule: source-text-is-the-product (see #17) +// Workflow markdown is the shipped runtime contract; validating its AskUserQuestion +// option limits is a behavioral guard, not an implementation-detail assertion. + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const ROOT = path.join(__dirname, '..', 'gsd-core', 'workflows'); +const ASK_USER_QUESTION_OPTION_CAP = 4; + +function walkMarkdownFiles(dir, out = []) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walkMarkdownFiles(full, out); + continue; + } + if (entry.isFile() && full.endsWith('.md')) out.push(full); + } + return out; +} + +function findBalancedClose(text, openIndex, openCh, closeCh) { + let depth = 0; + for (let i = openIndex; i < text.length; i++) { + const ch = text[i]; + if (ch === openCh) depth++; + else if (ch === closeCh) { + depth--; + if (depth === 0) return i; + } + } + return -1; +} + +function getLineNumber(text, index) { + return text.slice(0, index).split('\n').length; +} + +function collectOptionCapViolations(file, text) { + const violations = []; + const askRe = /\bAskUserQuestion\s*\(\s*\[/g; + let askMatch; + + while ((askMatch = askRe.exec(text)) !== null) { + const askStart = askMatch.index; + const arrayOpen = text.indexOf('[', askStart); + if (arrayOpen === -1) continue; + const arrayClose = findBalancedClose(text, arrayOpen, '[', ']'); + if (arrayClose === -1) continue; + const askBlock = text.slice(arrayOpen, arrayClose + 1); + const blockOffset = arrayOpen; + + const optionsRe = /\boptions\s*:\s*\[/g; + let optionsMatch; + while ((optionsMatch = optionsRe.exec(askBlock)) !== null) { + const openInBlock = optionsMatch.index + optionsMatch[0].length - 1; + const closeInBlock = findBalancedClose(askBlock, openInBlock, '[', ']'); + if (closeInBlock === -1) continue; + const optionsBody = askBlock.slice(openInBlock, closeInBlock + 1); + const labelCount = (optionsBody.match(/\blabel\s*:\s*"[^"]+"/g) || []).length; + if (labelCount > ASK_USER_QUESTION_OPTION_CAP) { + const globalIdx = blockOffset + optionsMatch.index; + violations.push({ + file, + line: getLineNumber(text, globalIdx), + count: labelCount, + }); + } + } + } + + return violations; +} + +describe('bug #17: AskUserQuestion options arrays respect runtime cap', () => { + test('every AskUserQuestion options array in workflows has at most 4 options', () => { + const files = walkMarkdownFiles(ROOT); + const violations = []; + + for (const file of files) { + const text = fs.readFileSync(file, 'utf8'); + violations.push(...collectOptionCapViolations(file, text)); + } + + assert.equal( + violations.length, + 0, + [ + `Found ${violations.length} AskUserQuestion options-array cap violation(s).`, + `Runtime cap is ${ASK_USER_QUESTION_OPTION_CAP} options per question.`, + ...violations.map((v) => { + const rel = path.relative(path.join(__dirname, '..'), v.file); + return ` ${rel}:${v.line} -> ${v.count} options`; + }), + ].join('\n') + ); + }); +}); + }); +} diff --git a/tests/bug-17-askuserquestion-option-cap.test.cjs b/tests/bug-17-askuserquestion-option-cap.test.cjs deleted file mode 100644 index bdf5d73d5..000000000 --- a/tests/bug-17-askuserquestion-option-cap.test.cjs +++ /dev/null @@ -1,103 +0,0 @@ -'use strict'; - -// allow-test-rule: source-text-is-the-product -// Workflow markdown is the shipped runtime contract; validating its AskUserQuestion -// option limits is a behavioral guard, not an implementation-detail assertion. - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const ROOT = path.join(__dirname, '..', 'gsd-core', 'workflows'); -const ASK_USER_QUESTION_OPTION_CAP = 4; - -function walkMarkdownFiles(dir, out = []) { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) { - walkMarkdownFiles(full, out); - continue; - } - if (entry.isFile() && full.endsWith('.md')) out.push(full); - } - return out; -} - -function findBalancedClose(text, openIndex, openCh, closeCh) { - let depth = 0; - for (let i = openIndex; i < text.length; i++) { - const ch = text[i]; - if (ch === openCh) depth++; - else if (ch === closeCh) { - depth--; - if (depth === 0) return i; - } - } - return -1; -} - -function getLineNumber(text, index) { - return text.slice(0, index).split('\n').length; -} - -function collectOptionCapViolations(file, text) { - const violations = []; - const askRe = /\bAskUserQuestion\s*\(\s*\[/g; - let askMatch; - - while ((askMatch = askRe.exec(text)) !== null) { - const askStart = askMatch.index; - const arrayOpen = text.indexOf('[', askStart); - if (arrayOpen === -1) continue; - const arrayClose = findBalancedClose(text, arrayOpen, '[', ']'); - if (arrayClose === -1) continue; - const askBlock = text.slice(arrayOpen, arrayClose + 1); - const blockOffset = arrayOpen; - - const optionsRe = /\boptions\s*:\s*\[/g; - let optionsMatch; - while ((optionsMatch = optionsRe.exec(askBlock)) !== null) { - const openInBlock = optionsMatch.index + optionsMatch[0].length - 1; - const closeInBlock = findBalancedClose(askBlock, openInBlock, '[', ']'); - if (closeInBlock === -1) continue; - const optionsBody = askBlock.slice(openInBlock, closeInBlock + 1); - const labelCount = (optionsBody.match(/\blabel\s*:\s*"[^"]+"/g) || []).length; - if (labelCount > ASK_USER_QUESTION_OPTION_CAP) { - const globalIdx = blockOffset + optionsMatch.index; - violations.push({ - file, - line: getLineNumber(text, globalIdx), - count: labelCount, - }); - } - } - } - - return violations; -} - -describe('bug #17: AskUserQuestion options arrays respect runtime cap', () => { - test('every AskUserQuestion options array in workflows has at most 4 options', () => { - const files = walkMarkdownFiles(ROOT); - const violations = []; - - for (const file of files) { - const text = fs.readFileSync(file, 'utf8'); - violations.push(...collectOptionCapViolations(file, text)); - } - - assert.equal( - violations.length, - 0, - [ - `Found ${violations.length} AskUserQuestion options-array cap violation(s).`, - `Runtime cap is ${ASK_USER_QUESTION_OPTION_CAP} options per question.`, - ...violations.map((v) => { - const rel = path.relative(path.join(__dirname, '..'), v.file); - return ` ${rel}:${v.line} -> ${v.count} options`; - }), - ].join('\n') - ); - }); -}); diff --git a/tests/bug-170-workflow-fallback-install-hint.test.cjs b/tests/bug-170-workflow-fallback-install-hint.test.cjs deleted file mode 100644 index 4a3dfa28d..000000000 --- a/tests/bug-170-workflow-fallback-install-hint.test.cjs +++ /dev/null @@ -1,43 +0,0 @@ -'use strict'; -// allow-test-rule: workflow markdown is shipped product text; this test validates fallback hint literals across all workflow files - -const { test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows'); -const LEGACY_HINT = 'npx get-shit-done-cc@latest --claude --local'; -const CURRENT_HINT = 'npx -y @opengsd/gsd-core@latest --claude --local'; - -function findMarkdownFiles(dir) { - const out = []; - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) out.push(...findMarkdownFiles(full)); - else if (entry.isFile() && full.endsWith('.md')) out.push(full); - } - return out; -} - -test('bug #170: workflow fallback hints do not reference get-shit-done-cc', () => { - const files = findMarkdownFiles(WORKFLOWS_DIR); - let legacyCount = 0; - let currentCount = 0; - - for (const file of files) { - const src = fs.readFileSync(file, 'utf8'); - if (src.includes(LEGACY_HINT)) legacyCount += 1; - if (src.includes(CURRENT_HINT)) currentCount += 1; - } - - assert.equal( - legacyCount, - 0, - `workflow fallback hints must not reference legacy package (${LEGACY_HINT})` - ); - assert.ok( - currentCount > 0, - `expected at least one workflow fallback hint to use current package (${CURRENT_HINT})` - ); -}); diff --git a/tests/bug-2002-offer-next-context.test.cjs b/tests/bug-2002-offer-next-context.test.cjs deleted file mode 100644 index ea1acadfe..000000000 --- a/tests/bug-2002-offer-next-context.test.cjs +++ /dev/null @@ -1,86 +0,0 @@ -/** - * Regression tests for bug #2002 - * - * offer_next in execute-phase.md must present conditional next steps - * based on whether CONTEXT.md already exists for the next phase. - * The previous flat list offered all options equally with no primary - * recommendation, leaving agents without guidance on the correct first step. - * - * Fixed: offer_next now checks for {next}-CONTEXT.md in the phase directory. - * - If CONTEXT.md is missing: primary suggestion is /gsd-discuss-phase - * - If CONTEXT.md exists: primary suggestion is /gsd-plan-phase - */ - -'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 workflowPath = path.resolve( - __dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md' -); - -describe('bug #2002: offer_next checks CONTEXT.md before suggesting next step', () => { - let content; - - // Read once — all tests share the same file content - test('setup: workflow file is readable', () => { - content = fs.readFileSync(workflowPath, 'utf-8'); - assert.ok(content.length > 0, 'execute-phase.md must not be empty'); - }); - - test('offer_next section checks for CONTEXT.md existence', () => { - content = content || fs.readFileSync(workflowPath, 'utf-8'); - // The workflow must check for CONTEXT.md in the next phase directory - assert.ok( - content.includes('CONTEXT.md'), - 'offer_next must reference CONTEXT.md to determine primary next step' - ); - }); - - test('offer_next presents /gsd-discuss-phase when CONTEXT.md does not exist', () => { - content = content || fs.readFileSync(workflowPath, 'utf-8'); - // Must have a conditional path where discuss-phase is the primary step - // when CONTEXT.md is missing — look for proximity of "not exist"/"missing"/ - // "does not exist" and "gsd-discuss-phase" in the offer_next step - const offerNextIdx = content.indexOf('offer_next'); - assert.ok(offerNextIdx !== -1, 'offer_next step must exist'); - - // Use 5000-char window — the step is ~60 lines of prose before the conditionals - const offerNextSection = content.slice(offerNextIdx, offerNextIdx + 5000); - assert.ok( - /CONTEXT\.md.*does not exist|CONTEXT\.md.*not.*exist|If CONTEXT\.md does/i.test(offerNextSection) || - /gsd-discuss-phase.*recommended|recommended.*gsd-discuss-phase/i.test(offerNextSection), - 'offer_next must present /gsd-discuss-phase as primary when CONTEXT.md does not exist' - ); - }); - - test('offer_next presents /gsd-plan-phase when CONTEXT.md exists', () => { - content = content || fs.readFileSync(workflowPath, 'utf-8'); - const offerNextIdx = content.indexOf('offer_next'); - assert.ok(offerNextIdx !== -1, 'offer_next step must exist'); - - const offerNextSection = content.slice(offerNextIdx, offerNextIdx + 5000); - assert.ok( - /CONTEXT\.md.*exists|exists.*CONTEXT\.md|If CONTEXT\.md/i.test(offerNextSection), - 'offer_next must present /gsd-plan-phase as primary when CONTEXT.md exists' - ); - }); - - test('offer_next section contains at least one conditional guard before listing commands', () => { - content = content || fs.readFileSync(workflowPath, 'utf-8'); - const offerNextIdx = content.indexOf('offer_next'); - assert.ok(offerNextIdx !== -1, 'offer_next step must exist'); - - const offerNextSection = content.slice(offerNextIdx, offerNextIdx + 5000); - - // The fixed version must contain at least one "If CONTEXT.md" conditional - // guard before presenting command options. The old flat list had no guard. - assert.ok( - /If CONTEXT\.md/i.test(offerNextSection), - 'offer_next must contain at least one "If CONTEXT.md" conditional guard' - ); - }); -}); diff --git a/tests/bug-2004-pr-branch-milestone.test.cjs b/tests/bug-2004-pr-branch-milestone.test.cjs deleted file mode 100644 index 35e84f956..000000000 --- a/tests/bug-2004-pr-branch-milestone.test.cjs +++ /dev/null @@ -1,88 +0,0 @@ -/** - * Regression tests for bug #2004 - * - * /gsd-pr-branch must not exclude milestone archive and structural planning - * commits. The previous implementation filtered ALL .planning/-only commits, - * including STATE.md, ROADMAP.md, MILESTONES.md, and milestones/** updates - * that are needed to preserve repository planning state after a merge. - * - * Fixed: pr-branch.md now distinguishes: - * - Transient planning commits (phase plans, summaries, research, context) → EXCLUDE - * - Structural planning commits (STATE.md, ROADMAP.md, MILESTONES.md, - * PROJECT.md, milestones/**) → INCLUDE - * - Code commits (any non-.planning/ file) → INCLUDE - * - Mixed commits (code + planning) → INCLUDE - */ - -'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 workflowPath = path.resolve( - __dirname, '..', 'gsd-core', 'workflows', 'pr-branch.md' -); - -describe('bug #2004: pr-branch preserves structural planning commits', () => { - let content; - - test('setup: pr-branch workflow is readable', () => { - content = fs.readFileSync(workflowPath, 'utf-8'); - assert.ok(content.length > 0, 'pr-branch.md must not be empty'); - }); - - test('workflow distinguishes structural vs transient planning commits', () => { - content = content || fs.readFileSync(workflowPath, 'utf-8'); - // Must contain language distinguishing structural from transient/phase planning files - assert.ok( - /structural|milestone.*archive|STATE\.md.*INCLUDE|preserve.*milestone|milestone.*preserve/i.test(content), - 'pr-branch.md must distinguish structural planning commits from transient ones' - ); - }); - - test('workflow lists STATE.md and ROADMAP.md as structural files to preserve', () => { - content = content || fs.readFileSync(workflowPath, 'utf-8'); - assert.ok( - content.includes('STATE.md'), - 'pr-branch.md must reference STATE.md as a structural file to preserve' - ); - assert.ok( - content.includes('ROADMAP.md'), - 'pr-branch.md must reference ROADMAP.md as a structural file to preserve' - ); - }); - - test('workflow lists MILESTONES.md or milestones/ as structural files to preserve', () => { - content = content || fs.readFileSync(workflowPath, 'utf-8'); - assert.ok( - content.includes('MILESTONES.md') || content.includes('milestones/'), - 'pr-branch.md must reference MILESTONES.md or milestones/ as structural files to preserve' - ); - }); - - test('workflow has four commit categories (code, planning-only, mixed, structural)', () => { - content = content || fs.readFileSync(workflowPath, 'utf-8'); - // Must have at least a "structural" or "milestone" category beyond the original three - assert.ok( - /structural.*commit|milestone.*commit|commit.*structural|commit.*milestone/i.test(content) || - /INCLUDE.*STATE\.md|STATE\.md.*INCLUDE/i.test(content), - 'pr-branch.md must classify structural planning commits as INCLUDE' - ); - }); - - test('create_pr_branch step does not rm -r --cached all of .planning/', () => { - content = content || fs.readFileSync(workflowPath, 'utf-8'); - // The original bug: `git rm -r --cached .planning/` nuked structural files. - // The fix must either remove this wholesale rm or scope it to transient dirs. - // Acceptable: narrowed rm targeting only phase/, quick/, research/, etc. - // Not acceptable: `git rm -r --cached .planning/` with no scoping. - const hasUnscoped = /git rm -r --cached \.planning\/(?!\*)?(?!phases|quick|research|threads|todos|debug|seeds|ui-reviews|codebase)/ - .test(content); - assert.ok( - !hasUnscoped, - 'create_pr_branch must not use unscoped "git rm -r --cached .planning/" — scope to transient subdirectories only' - ); - }); -}); diff --git a/tests/bug-222-research-synthesizer-write-contract.test.cjs b/tests/bug-222-research-synthesizer-write-contract.test.cjs deleted file mode 100644 index eb97ccd38..000000000 --- a/tests/bug-222-research-synthesizer-write-contract.test.cjs +++ /dev/null @@ -1,115 +0,0 @@ -// allow-test-rule: source-text-is-the-product -'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 REPO_ROOT = path.resolve(__dirname, '..'); -const SYNTHESIZER_PATH = path.join(REPO_ROOT, 'agents', 'gsd-research-synthesizer.md'); - -function readSynthesizerPrompt() { - return fs.readFileSync(SYNTHESIZER_PATH, 'utf8'); -} - -describe('bug #222: research synthesizer must write SUMMARY.md via Write tool', () => { - test('step 6 has explicit hard-rule block forbidding return-message content fallback', () => { - const prompt = readSynthesizerPrompt(); - - assert.match( - prompt, - /canonical output of this agent[\s\S]*existing on disk after you return/i, - 'Step 6 must define SUMMARY.md-on-disk as canonical output.' - ); - assert.match( - prompt, - /Hard rules \(must follow\):/i, - 'Step 6 must contain explicit hard rules block.' - ); - assert.match( - prompt, - /Use the `Write` tool[\s\S]*there are no restrictions/i, - 'Rule 1 must force Write tool usage and reject hallucinated restrictions.' - ); - assert.match( - prompt, - /Do NOT return the SUMMARY\.md content in your response/i, - 'Rule 2 must forbid returning SUMMARY content in the response body.' - ); - assert.match( - prompt, - /Do NOT ask permission to write/i, - 'Rule 3 must forbid write-permission asks for this agent.' - ); - assert.match( - prompt, - /Do NOT use `Bash\(cat << 'EOF'\)` or heredoc/i, - 'Rule 4 must forbid heredoc/Bash file creation fallback.' - ); - assert.match( - prompt, - /If the Write tool errors[\s\S]*Do not silently fall back to returning content/i, - 'Rule 5 must force explicit error reporting for Write failures.' - ); - }); -}); - -describe('bug #222 recurrence: orchestrator self-heals when synthesizer returns SUMMARY.md inline', () => { - const WORKFLOWS = [ - path.join(REPO_ROOT, 'gsd-core', 'workflows', 'new-project.md'), - path.join(REPO_ROOT, 'gsd-core', 'workflows', 'new-milestone.md'), - ]; - - for (const wf of WORKFLOWS) { - const name = path.basename(wf); - - test(`${name} has the #222 synthesizer SUMMARY.md self-heal guard`, () => { - const text = fs.readFileSync(wf, 'utf8'); - - // Marker tying the guard to the issue - assert.match(text, /#222[^\n]*self-heal|self-heal[^\n]*#222/i, - `${name} must contain a #222-tagged self-heal guard after the synthesizer returns.`); - - // Verifies the file exists AND is substantive/non-empty - assert.match(text, /SUMMARY\.md[\s\S]{0,120}?(non-empty|substantive|exists)/i, - `${name} must verify .planning/research/SUMMARY.md exists AND is substantive — non-empty.`); - - // Truncation/validator guard: references the continuation sentinel OR the verify-summary CLI - assert.match(text, /gsd:write-continue|verify-summary/i, - `${name} must guard against truncated/invalid SUMMARY.md (sentinel or verify-summary).`); - - // Self-heal must commit ALL research artifacts, not just SUMMARY.md - assert.match(text, /--files \.planning\/research\//, - `${name} self-heal must commit ALL research artifacts, not just SUMMARY.md.`); - - // Persists inline-returned document via Write rather than trusting the agent - assert.match(text, /returned[\s\S]{0,200}?document[\s\S]{0,200}?Write tool/i, - `${name} must instruct the orchestrator to persist inline-returned document with the Write tool.`); - - // Must not proceed to roadmapper against a missing or incomplete SUMMARY.md - assert.match(text, /gsd-roadmapper[\s\S]{0,200}?(missing|incomplete|do NOT)/i, - `${name} must block spawning gsd-roadmapper when SUMMARY.md is missing or incomplete.`); - - // Must name the FULL SUMMARY template markers so the orchestrator persists the real - // document, not the brief structured return (resolves the HIGH finding). - assert.match(text, /# Project Research Summary[\s\S]{0,260}?## Sources/, - `${name}: self-heal must name the full SUMMARY.md template markers (# Project Research Summary … ## Sources).`); - // Must reference the brief confirmation marker it must NOT mistake for file content. - assert.match(text, /## SYNTHESIS COMPLETE/, - `${name}: self-heal must distinguish the brief ## SYNTHESIS COMPLETE confirmation from the real document.`); - }); - - test(`${name} runs the #222 self-heal AFTER the synthesizer and BEFORE gsd-roadmapper`, () => { - const text = fs.readFileSync(wf, 'utf8'); - const synthIdx = text.indexOf('subagent_type="gsd-research-synthesizer"'); - const healIdx = text.indexOf('Synthesizer output self-heal (#222)'); - const roadIdx = text.indexOf('subagent_type="gsd-roadmapper"'); - assert.ok(synthIdx >= 0, `${name}: synthesizer dispatch not found`); - assert.ok(healIdx >= 0, `${name}: #222 self-heal block not found`); - assert.ok(roadIdx >= 0, `${name}: gsd-roadmapper dispatch not found`); - assert.ok(healIdx > synthIdx, `${name}: self-heal must come AFTER the synthesizer dispatch`); - assert.ok(roadIdx > healIdx, `${name}: self-heal must come BEFORE the gsd-roadmapper dispatch`); - }); - } -}); diff --git a/tests/bug-2384-post-merge-deletion-audit.test.cjs b/tests/bug-2384-post-merge-deletion-audit.test.cjs deleted file mode 100644 index ceb513c87..000000000 --- a/tests/bug-2384-post-merge-deletion-audit.test.cjs +++ /dev/null @@ -1,79 +0,0 @@ -'use strict'; - -/** - * Regression test for #2384. - * - * During execute-phase, the orchestrator merges per-plan worktree branches into - * main. The pre-merge deletion check (git diff --diff-filter=D HEAD...WT_BRANCH) - * only catches files deleted on the worktree branch. A post-merge audit is also - * required to catch deletions that made it into the merge commit (e.g., files - * that were in the common ancestor but deleted by the merged worktree) and to - * provide a revert safety net. - * - * After #3797: execute-phase.md delegates worktree cleanup to the SDK's - * worktree.cleanup-wave command, which implements pre-merge deletion checks - * (diff --diff-filter=D) internally via executeWorktreeWaveCleanupPlan. - * The manual post-merge shell audit (MERGE_DEL_COUNT, git reset --hard) has - * been removed from the workflow — it was part of the SDK-absence fallback. - */ - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const EXECUTE_PHASE = path.join( - __dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md' -); - -/** - * Parse execute-phase.md into a structured contract object. - * Returns typed boolean fields so tests can assert on structure - * rather than raw text. - */ -function parseExecutePhaseContract(filePath) { - const lines = fs.readFileSync(filePath, 'utf-8').split(/\r?\n/); - return { - // Does the workflow call the worktree.cleanup-wave SDK command? - delegatesToCleanupWave: lines.some(l => l.includes('worktree.cleanup-wave')), - // Does the cleanup-wave invocation use || exit 1 (fail-closed)? - cleanupWaveFailClosed: lines.some( - l => /gsd_run query worktree\.cleanup-wave.*\|\| exit 1/.test(l), - ), - // Does the workflow export/reference WAVE_WORKTREE_MANIFEST for the SDK? - passesWaveManifest: lines.some(l => l.includes('WAVE_WORKTREE_MANIFEST')), - }; -} - -describe('execute-phase.md — post-merge deletion audit (#2384)', () => { - const contract = parseExecutePhaseContract(EXECUTE_PHASE); - - test('execute-phase delegates to worktree.cleanup-wave (which handles deletion audit)', () => { - // After #3797: worktree.cleanup-wave in worktree-safety.cjs performs - // diff --diff-filter=D checks (blocks branches with deletions) before merge. - // The workflow delegates to the SDK rather than duplicating the check inline. - assert.ok( - contract.delegatesToCleanupWave, - 'execute-phase.md must delegate to gsd_run query worktree.cleanup-wave (#2384/#3797)', - ); - }); - - test('execute-phase cleanup-wave uses || exit 1 (fail-closed for blocked deletions)', () => { - // If worktree.cleanup-wave detects deletions, it exits 1 (blocked). - // The || exit 1 in the workflow propagates that refusal rather than swallowing it. - assert.ok( - contract.cleanupWaveFailClosed, - 'execute-phase.md must use || exit 1 so deletion-blocked cleanups surface to the orchestrator', - ); - }); - - test('execute-phase still has pre-merge deletion check (via guard before worktree.cleanup-wave)', () => { - // The primary deletion guard is now in worktree-safety.cjs (SDK). - // The workflow must still enforce WAVE_WORKTREE_MANIFEST so the SDK - // has the info it needs to validate branches. - assert.ok( - contract.passesWaveManifest, - 'execute-phase.md must pass WAVE_WORKTREE_MANIFEST to worktree.cleanup-wave', - ); - }); -}); diff --git a/tests/bug-2388-plan-phase-no-branch-rename.test.cjs b/tests/bug-2388-plan-phase-no-branch-rename.test.cjs deleted file mode 100644 index 207c9bf4d..000000000 --- a/tests/bug-2388-plan-phase-no-branch-rename.test.cjs +++ /dev/null @@ -1,66 +0,0 @@ -// allow-test-rule: source-text-is-the-product -// Workflow .md / agent .md / command .md / reference .md files — their text -// IS what the runtime loads. Testing text content tests the deployed contract. -// Per CONTRIBUTING.md exception matrix. - -/** - * Regression test for #2388: plan-phase silently renames feature branch - * when phase slug has changed since the branch was created. - * - * Fix: plan-phase.md must include an explicit instruction not to create, - * rename, or switch git branches during the planning workflow. - */ - -'use strict'; - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const PLAN_PHASE_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'plan-phase.md'); - -describe('bug-2388: plan-phase must not rename or create git branches', () => { - test('plan-phase.md exists', () => { - assert.ok(fs.existsSync(PLAN_PHASE_PATH), 'plan-phase.md should exist'); - }); - - test('plan-phase.md contains explicit no-branch-rename instruction', () => { - const content = fs.readFileSync(PLAN_PHASE_PATH, 'utf-8'); - // Must say "do not" and mention branch in the context of phase slug/rename - const hasBranchGuard = ( - /do not.{0,80}branch/i.test(content) || - /branch.{0,80}do not/i.test(content) || - /NEVER.{0,80}branch/i.test(content) || - /branch.{0,80}NEVER/i.test(content) - ); - assert.ok( - hasBranchGuard, - 'plan-phase.md must include an explicit instruction not to create or rename git branches' - ); - }); - - test('plan-phase.md mentions phase rename does not affect branch name', () => { - const content = fs.readFileSync(PLAN_PHASE_PATH, 'utf-8'); - // Should explain that a phase rename in ROADMAP.md is plan-level, not git-level - const hasPlanLevelExplanation = ( - content.includes('phase rename') || - content.includes('phase_slug') || - content.includes('branch identity') || - content.includes('branch name') - ); - assert.ok( - hasPlanLevelExplanation, - 'plan-phase.md should clarify that phase slug changes do not change the git branch' - ); - }); - - test('plan-phase.md does not contain git checkout -b instruction', () => { - const content = fs.readFileSync(PLAN_PHASE_PATH, 'utf-8'); - // The workflow should not instruct the LLM to run git checkout -b - assert.ok( - !content.includes('git checkout -b'), - 'plan-phase.md must not instruct LLM to create a new branch via git checkout -b' - ); - }); -}); diff --git a/tests/bug-2396-makefile-test-priority.test.cjs b/tests/bug-2396-makefile-test-priority.test.cjs deleted file mode 100644 index de2697dcd..000000000 --- a/tests/bug-2396-makefile-test-priority.test.cjs +++ /dev/null @@ -1,117 +0,0 @@ -// allow-test-rule: source-text-is-the-product -// Workflow .md / agent .md / command .md / reference .md files — their text -// IS what the runtime loads. Testing text content tests the deployed contract. -// Per CONTRIBUTING.md exception matrix. - -/** - * Regression test for #2396: hardcoded host-level test commands bypass - * container-only project Makefiles. - * - * Fix: execute-phase.md, verify-phase.md, and audit-fix.md must check for - * Makefile with a test target (and other wrappers) before falling through - * to hardcoded language-sniffed commands. - */ - -'use strict'; - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const EXECUTE_PHASE_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md'); -const VERIFY_PHASE_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'verify-phase.md'); -const AUDIT_FIX_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'audit-fix.md'); - -function assertMakefileCheckBeforeNpmTest(filePath, label) { - const content = fs.readFileSync(filePath, 'utf-8'); - - // Must check for Makefile with test target - const hasMakefileCheck = /Makefile.*grep.*test:|grep.*test:.*Makefile/s.test(content) || - (content.includes('Makefile') && content.includes('"^test:"')); - assert.ok( - hasMakefileCheck, - `${label}: must check for Makefile with test: target before falling through to hardcoded commands` - ); - - // make test must appear before npm test in the file - const makeTestIdx = content.indexOf('make test'); - const npmTestIdx = content.indexOf('npm test'); - assert.ok(makeTestIdx !== -1, `${label}: must contain "make test"`); - assert.ok(npmTestIdx !== -1, `${label}: must still contain "npm test" as fallback`); - assert.ok( - makeTestIdx < npmTestIdx, - `${label}: "make test" must appear before "npm test" (Makefile takes priority)` - ); -} - -function assertConfigGetBeforeMakefile(filePath, label) { - const content = fs.readFileSync(filePath, 'utf-8'); - // Must check workflow.test_command config before Makefile sniff. - // Verify within each bash code block: the workflow.test_command lookup - // appears before the Makefile grep in the same block. - assert.ok( - content.includes('workflow.test_command'), - `${label}: must check workflow.test_command config before Makefile/language sniff` - ); - - // Extract bash blocks to check ordering within each block. - // Use the actual Makefile test ([ -f "Makefile" ]) not just the word "Makefile" - // (which appears in comments before the config-get call). - const bashBlockRe = /```bash([\s\S]*?)```/g; - let match; - let anyBlockCorrectlyOrdered = false; - while ((match = bashBlockRe.exec(content)) !== null) { - const block = match[1]; - if (block.includes('workflow.test_command') && block.includes('[ -f "Makefile"')) { - const configIdx = block.indexOf('workflow.test_command'); - const makefileIdx = block.indexOf('[ -f "Makefile"'); - if (configIdx < makefileIdx) { - anyBlockCorrectlyOrdered = true; - break; - } - } - } - assert.ok( - anyBlockCorrectlyOrdered, - `${label}: within a bash block, workflow.test_command config check must appear before Makefile test ([ -f "Makefile" ])` - ); -} - -describe('bug-2396: Makefile test target must take priority over hardcoded commands', () => { - test('execute-phase.md exists', () => { - assert.ok(fs.existsSync(EXECUTE_PHASE_PATH), 'execute-phase.md should exist'); - }); - - test('verify-phase.md exists', () => { - assert.ok(fs.existsSync(VERIFY_PHASE_PATH), 'verify-phase.md should exist'); - }); - - test('audit-fix.md exists', () => { - assert.ok(fs.existsSync(AUDIT_FIX_PATH), 'audit-fix.md should exist'); - }); - - test('execute-phase.md: Makefile check precedes npm test (post-merge gate)', () => { - assertMakefileCheckBeforeNpmTest(EXECUTE_PHASE_PATH, 'execute-phase.md'); - }); - - test('verify-phase.md: Makefile check precedes npm test', () => { - assertMakefileCheckBeforeNpmTest(VERIFY_PHASE_PATH, 'verify-phase.md'); - }); - - test('audit-fix.md: Makefile check precedes npm test', () => { - assertMakefileCheckBeforeNpmTest(AUDIT_FIX_PATH, 'audit-fix.md'); - }); - - test('execute-phase.md: workflow.test_command config checked first (within bash block)', () => { - assertConfigGetBeforeMakefile(EXECUTE_PHASE_PATH, 'execute-phase.md'); - }); - - test('verify-phase.md: workflow.test_command config checked first (within bash block)', () => { - assertConfigGetBeforeMakefile(VERIFY_PHASE_PATH, 'verify-phase.md'); - }); - - test('audit-fix.md: workflow.test_command config checked first (within bash block)', () => { - assertConfigGetBeforeMakefile(AUDIT_FIX_PATH, 'audit-fix.md'); - }); -}); diff --git a/tests/bug-2399-commit-docs-plan-phase.test.cjs b/tests/bug-2399-commit-docs-plan-phase.test.cjs deleted file mode 100644 index 518b248d0..000000000 --- a/tests/bug-2399-commit-docs-plan-phase.test.cjs +++ /dev/null @@ -1,77 +0,0 @@ -// allow-test-rule: source-text-is-the-product -// Workflow .md / agent .md / command .md / reference .md files — their text -// IS what the runtime loads. Testing text content tests the deployed contract. -// Per CONTRIBUTING.md exception matrix. - -/** - * Bug #2399: commit_docs:true is ignored in plan-phase - * - * The plan-phase workflow generates plan artifacts but never commits them even - * when commit_docs is true. A step between 13b and 14 must commit the PLAN.md - * files and updated STATE.md when commit_docs is set. - */ - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const PLAN_PHASE_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'plan-phase.md'); - -describe('plan-phase commit_docs support (#2399)', () => { - test('plan-phase.md exists', () => { - assert.ok(fs.existsSync(PLAN_PHASE_PATH), 'gsd-core/workflows/plan-phase.md must exist'); - }); - - test('plan-phase.md has a commit step for plan artifacts', () => { - const content = fs.readFileSync(PLAN_PHASE_PATH, 'utf-8'); - // Must contain a commit call that references PLAN.md files - assert.ok( - content.includes('PLAN.md') && content.includes('commit'), - 'plan-phase.md must include a commit step that references PLAN.md files' - ); - }); - - test('plan-phase.md commit step is gated on commit_docs', () => { - const content = fs.readFileSync(PLAN_PHASE_PATH, 'utf-8'); - // The commit step must be conditional on commit_docs - assert.ok( - content.includes('commit_docs'), - 'plan-phase.md must reference commit_docs to gate the plan commit step' - ); - }); - - test('plan-phase.md commit step references STATE.md', () => { - const content = fs.readFileSync(PLAN_PHASE_PATH, 'utf-8'); - // Should commit STATE.md alongside PLAN.md files - assert.ok( - content.includes('STATE.md'), - 'plan-phase.md commit step should include STATE.md to capture planning completion state' - ); - }); - - test('plan-phase.md has a step 13c that commits plan artifacts', () => { - const content = fs.readFileSync(PLAN_PHASE_PATH, 'utf-8'); - const step13b = content.indexOf('## 13b.'); - const step14 = content.indexOf('## 14.'); - // Look for the step 13c section (or any commit step between 13b and 14) - const step13c = content.indexOf('## 13c.'); - - assert.ok(step13b !== -1, '## 13b. section must exist'); - assert.ok(step14 !== -1, '## 14. section must exist'); - assert.ok(step13c !== -1, '## 13c. step must exist (commit plans step)'); - assert.ok( - step13c > step13b && step13c < step14, - `Step 13c (at ${step13c}) must appear between step 13b (at ${step13b}) and step 14 (at ${step14})` - ); - }); - - test('plan-phase.md uses gsd-sdk query commit for the plan commit', () => { - const content = fs.readFileSync(PLAN_PHASE_PATH, 'utf-8'); - // Must use gsd-sdk query commit (not raw git) so commit_docs guard in gsd-tools is respected - assert.ok( - content.includes('gsd-sdk query commit') || content.includes('gsd-tools') || content.includes('gsd-sdk'), - 'plan-phase.md plan commit step must use gsd-sdk query commit (not raw git commit)' - ); - }); -}); diff --git a/tests/bug-2410-stream-checkpoint-heartbeats.test.cjs b/tests/bug-2410-stream-checkpoint-heartbeats.test.cjs deleted file mode 100644 index c43ed238d..000000000 --- a/tests/bug-2410-stream-checkpoint-heartbeats.test.cjs +++ /dev/null @@ -1,178 +0,0 @@ -// allow-test-rule: source-text-is-the-product -// Workflow .md / agent .md / command .md / reference .md files — their text -// IS what the runtime loads. Testing text content tests the deployed contract. -// Per CONTRIBUTING.md exception matrix. - -/** - * Bug #2410 — /gsd:manager background execute-phase Task fails with - * "Stream idle timeout" on multi-plan phases. - * - * Fix: execute-phase.md instructs the orchestrator to emit `[checkpoint]` - * heartbeat lines at every wave boundary AND every plan boundary so the - * Claude API SSE stream never idles long enough to trigger the platform - * timeout. This test validates the workflow contract that backs that fix. - */ - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const WORKFLOW_PATH = path.join( - __dirname, - '..', - 'gsd-core', - 'workflows', - 'execute-phase.md' -); -const COMMANDS_DOC_PATH = path.join(__dirname, '..', 'docs', 'COMMANDS.md'); - -describe('bug #2410: execute-phase emits checkpoint heartbeats', () => { - const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf-8'); - - test('workflow references the stream idle timeout symptom by name', () => { - assert.ok( - /Stream idle timeout/.test(workflow), - 'workflow should name the API error it is preventing' - ); - assert.ok( - workflow.includes('#2410'), - 'workflow should cite the tracking issue for future maintainers' - ); - }); - - test('workflow defines a [checkpoint] heartbeat line format', () => { - assert.ok( - workflow.includes('[checkpoint]'), - 'workflow should document the [checkpoint] marker prefix' - ); - }); - - test('workflow emits a wave-start heartbeat (A: wave-boundary checkpoint)', () => { - assert.ok( - /\[checkpoint\][^\r\n]*wave \{N\}\/\{M\} starting/.test(workflow), - 'workflow should emit a wave-start [checkpoint] marker before spawning agents' - ); - }); - - test('workflow emits a wave-complete heartbeat (A: wave-boundary checkpoint)', () => { - assert.ok( - /\[checkpoint\][^\r\n]*wave \{N\}\/\{M\} complete/.test(workflow), - 'workflow should emit a wave-complete [checkpoint] marker after spot-checks' - ); - }); - - test('workflow emits a plan-start heartbeat (B: plan-boundary checkpoint)', () => { - assert.ok( - /\[checkpoint\][^\r\n]*plan \{plan_id\} starting/.test(workflow), - 'workflow should emit a plan-start [checkpoint] marker before each Task() dispatch' - ); - }); - - test('workflow emits a plan-complete heartbeat (B: plan-boundary checkpoint)', () => { - assert.ok( - /\[checkpoint\][^\r\n]*plan \{plan_id\} complete/.test(workflow), - 'workflow should emit a plan-complete [checkpoint] marker after executor returns' - ); - }); - - test('workflow handles plan failure and checkpoint-gate heartbeats too', () => { - assert.ok( - /\[checkpoint\][^\r\n]*plan \{plan_id\} failed/.test(workflow), - 'workflow should emit a plan-failed [checkpoint] marker on executor error' - ); - assert.ok( - /\[checkpoint\][^\r\n]*plan \{plan_id\} checkpoint/.test(workflow), - 'workflow should emit a heartbeat when a plan returns a human-gate checkpoint' - ); - }); - - test('heartbeats include a monotonic plans-done counter', () => { - // The {P}/{Q} counter lets grep-based recovery tools reconstruct progress - // from a truncated transcript if the agent dies mid-phase. - assert.ok( - /\{P\}\/\{Q\} plans done/.test(workflow), - 'heartbeats should include a {P}/{Q} phase-wide completed-plan counter' - ); - }); - - test('wave-start heartbeat precedes the "Describe what\'s being built" text', () => { - const describeIdx = workflow.indexOf("Describe what's being built"); - const heartbeatIdx = workflow.indexOf( - '[checkpoint] phase {PHASE_NUMBER} wave {N}/{M} starting' - ); - assert.ok(describeIdx !== -1, 'workflow should still have the describe step'); - assert.ok(heartbeatIdx !== -1, 'wave-start heartbeat template should be present'); - // The instruction to emit the heartbeat appears in step 2, which is the - // step titled "Describe what's being built". The actual sentinel text we - // look for is the inline literal template — it must be emitted BEFORE any - // tool calls in that step. - const step2 = workflow.slice( - describeIdx, - workflow.indexOf('3. **Spawn executor agents', describeIdx) - ); - assert.ok( - step2.includes('[checkpoint]'), - 'step 2 should instruct the orchestrator to emit a [checkpoint] heartbeat' - ); - assert.ok( - /before any further reasoning or spawning/i.test(step2) || - /before any tool call/i.test(step2) || - /no tool call/i.test(step2), - 'step 2 should make clear the heartbeat is an assistant-text line, not a tool call' - ); - }); - - test('plan-start heartbeat is inside the spawn step', () => { - const spawnIdx = workflow.indexOf('3. **Spawn executor agents'); - const waitIdx = workflow.indexOf('4. **Wait for all agents', spawnIdx); - assert.ok(spawnIdx !== -1 && waitIdx !== -1, 'spawn and wait steps must exist'); - const step3 = workflow.slice(spawnIdx, waitIdx); - assert.ok( - /\[checkpoint\][^\r\n]*plan \{plan_id\} starting/.test(step3), - 'plan-start heartbeat should be emitted inside step 3 (spawn executor agents)' - ); - }); - - test('plan-complete and wave-complete heartbeats are inside the wait/report steps', () => { - const waitIdx = workflow.indexOf('4. **Wait for all agents'); - const hookIdx = workflow.indexOf('5. **Post-wave hook validation', waitIdx); - assert.ok(waitIdx !== -1 && hookIdx !== -1, 'wait + hook steps must exist'); - const step4 = workflow.slice(waitIdx, hookIdx); - assert.ok( - /\[checkpoint\][^\r\n]*plan \{plan_id\} complete/.test(step4), - 'plan-complete heartbeat should be emitted in step 4 (wait for agents)' - ); - - const reportIdx = workflow.indexOf('6. **Report completion'); - const failureIdx = workflow.indexOf('7. **Handle failures', reportIdx); - assert.ok(reportIdx !== -1 && failureIdx !== -1, 'report + failure steps must exist'); - const step6 = workflow.slice(reportIdx, failureIdx); - assert.ok( - /\[checkpoint\][^\r\n]*wave \{N\}\/\{M\} complete/.test(step6), - 'wave-complete heartbeat should be emitted in step 6 (report completion)' - ); - }); -}); - -describe('bug #2410: checkpoint heartbeat format is user-documented', () => { - const commandsDoc = fs.readFileSync(COMMANDS_DOC_PATH, 'utf-8'); - - test('COMMANDS.md documents the [checkpoint] format under /gsd-manager', () => { - const managerIdx = commandsDoc.indexOf('### `/gsd-manager`'); - assert.ok(managerIdx !== -1, '/gsd-manager section should exist'); - const section = commandsDoc.slice(managerIdx, managerIdx + 4000); - assert.ok( - /\[checkpoint\]/.test(section), - 'COMMANDS.md /gsd-manager section should document [checkpoint] heartbeat markers' - ); - assert.ok( - /Stream idle timeout/i.test(section), - 'COMMANDS.md should explain what the heartbeats prevent' - ); - assert.ok( - /#2410/.test(section), - 'COMMANDS.md should reference the tracking issue' - ); - }); -}); diff --git a/tests/bug-2432-quick-plan-predispatch-commit.test.cjs b/tests/bug-2432-quick-plan-predispatch-commit.test.cjs deleted file mode 100644 index 950b969c2..000000000 --- a/tests/bug-2432-quick-plan-predispatch-commit.test.cjs +++ /dev/null @@ -1,139 +0,0 @@ -/** - * Bug #2432: quick.md PLAN.md timing — worktree executor can't read PLAN.md - * - * The orchestrator must commit PLAN.md to the base branch BEFORE spawning the - * worktree-isolated executor. Without this, the executor's first Read resolves - * to a main-repo absolute path (not a worktree path), which primes CC's path - * cache and causes subsequent Edit/Write calls to silently target the main repo - * instead of the worktree (CC issue #36182 amplifier). - * - * Fix: Step 5.6 commits PLAN.md pre-dispatch when USE_WORKTREES is active. - */ - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const QUICK_MD = path.join(__dirname, '..', 'gsd-core', 'workflows', 'quick.md'); - -describe('quick.md pre-dispatch PLAN.md commit (#2432)', () => { - let content; - - test('quick.md exists', () => { - assert.ok(fs.existsSync(QUICK_MD), 'gsd-core/workflows/quick.md must exist'); - content = fs.readFileSync(QUICK_MD, 'utf-8'); - }); - - test('Step 5.6 exists between Step 5.5 and Step 6', () => { - const step55 = content.indexOf('Step 5.5'); - const step56 = content.indexOf('Step 5.6'); - const step6 = content.indexOf('Step 6:'); - assert.ok(step55 !== -1, 'Step 5.5 must exist'); - assert.ok(step56 !== -1, 'Step 5.6 must exist'); - assert.ok(step6 !== -1, 'Step 6 must exist'); - assert.ok(step56 > step55, 'Step 5.6 must appear after Step 5.5'); - assert.ok(step56 < step6, 'Step 5.6 must appear before Step 6'); - }); - - test('Step 5.6 is gated on USE_WORKTREES', () => { - const step56Start = content.indexOf('Step 5.6'); - const step6Start = content.indexOf('Step 6:', step56Start); - const step56Block = content.slice(step56Start, step6Start); - assert.ok( - step56Block.includes('USE_WORKTREES'), - 'Step 5.6 must be gated on USE_WORKTREES — only commit pre-dispatch in worktree mode' - ); - }); - - test('Step 5.6 is gated on commit_docs', () => { - const step56Start = content.indexOf('Step 5.6'); - const step6Start = content.indexOf('Step 6:', step56Start); - const step56Block = content.slice(step56Start, step6Start); - assert.ok( - step56Block.includes('commit_docs'), - 'Step 5.6 must respect commit_docs config — skip pre-dispatch commit when commit_docs is false' - ); - }); - - test('Step 5.6 stages and commits PLAN.md', () => { - const step56Start = content.indexOf('Step 5.6'); - const step6Start = content.indexOf('Step 6:', step56Start); - const step56Block = content.slice(step56Start, step6Start); - assert.ok( - step56Block.includes('PLAN.md'), - 'Step 5.6 must reference PLAN.md in the pre-dispatch commit' - ); - assert.ok( - step56Block.includes('git add') || step56Block.includes('git commit'), - 'Step 5.6 must include git add/commit to stage and commit PLAN.md' - ); - }); - - test('Step 5.6 uses --no-verify to avoid hook interference', () => { - const step56Start = content.indexOf('Step 5.6'); - const step6Start = content.indexOf('Step 6:', step56Start); - const step56Block = content.slice(step56Start, step6Start); - assert.ok( - step56Block.includes('--no-verify'), - 'Step 5.6 pre-dispatch commit must use --no-verify to avoid hook interference' - ); - }); - - test('executor prompt references PLAN.md via relative (worktree-rooted) path', () => { - // QUICK_DIR is always set to ".planning/quick/..." (relative) so ${QUICK_DIR}/...PLAN.md - // resolves relative to the worktree root, not the main repo absolute path. - // Verify the executor prompt uses QUICK_DIR variable (not a hardcoded absolute path). - const executorTask = content.indexOf('subagent_type="gsd-executor"'); - assert.ok(executorTask !== -1, 'executor Task() spawn must exist'); - // Find the files_to_read block near the executor spawn - const filesBlock = content.lastIndexOf('', executorTask); - const filesBlockEnd = content.indexOf('', filesBlock); - const filesContent = content.slice(filesBlock, filesBlockEnd); - assert.ok( - filesContent.includes('QUICK_DIR') || filesContent.includes('.planning/quick'), - 'executor files_to_read must reference PLAN.md via relative QUICK_DIR path' - ); - assert.ok( - !filesContent.match(/\/home\/|\/Users\/|\/root\//), - 'executor files_to_read must NOT contain hardcoded absolute paths' - ); - }); - - test('#1265 records both parent base and plan commit for worktree dispatch', () => { - const step56Start = content.indexOf('Step 5.6'); - const step6Start = content.indexOf('Step 6:', step56Start); - const step56Block = content.slice(step56Start, step6Start); - const step6Block = content.slice(step6Start, content.indexOf('After executor returns:', step6Start)); - - assert.ok( - step56Block.includes('QUICK_PLAN_PARENT'), - 'quick worktree mode must record the pre-dispatch parent SHA before committing PLAN.md (#1265)' - ); - assert.ok( - step56Block.includes('QUICK_PLAN_COMMIT'), - 'quick worktree mode must record the PLAN.md commit SHA after the pre-dispatch commit (#1265)' - ); - assert.ok( - step6Block.includes('EXPECTED_BASE_ALTERNATE'), - 'executor guard embed must carry the alternate accepted base for parent-vs-plan worktree forks (#1265)' - ); - }); - - test('#1265 executor materializes PLAN.md from the plan commit when worktree starts at parent', () => { - const executorTask = content.indexOf('subagent_type="gsd-executor"'); - assert.ok(executorTask !== -1, 'executor Task() spawn must exist'); - const promptStart = content.lastIndexOf('prompt="', executorTask); - const promptEnd = content.indexOf('subagent_type="gsd-executor"', promptStart); - const executorPrompt = content.slice(promptStart, promptEnd); - - assert.ok( - executorPrompt.includes('git show') && executorPrompt.includes('QUICK_PLAN_COMMIT'), - 'executor prompt must materialize PLAN.md from git show : when absent (#1265)' - ); - assert.ok( - executorPrompt.indexOf('git show') < executorPrompt.indexOf(''), - 'PLAN.md materialization must be a first action before files_to_read can prime paths (#1265)' - ); - }); -}); diff --git a/tests/bug-2470-update-md-claude-path.test.cjs b/tests/bug-2470-update-md-claude-path.test.cjs deleted file mode 100644 index 83cc17a32..000000000 --- a/tests/bug-2470-update-md-claude-path.test.cjs +++ /dev/null @@ -1,41 +0,0 @@ -// allow-test-rule: source-text-is-the-product -// Workflow .md / agent .md / command .md / reference .md files — their text -// IS what the runtime loads. Testing text content tests the deployed contract. -// Per CONTRIBUTING.md exception matrix. -'use strict'; - - -/** - * Regression test for #2470. - * - * update.md is installed into every runtime directory including .gemini, .codex, - * .opencode, etc. The installer's scanForLeakedPaths() uses the regex - * /(?:~|\$HOME)\/\.claude\b/g to detect unresolved .claude path references after - * copyWithPathReplacement() runs. The replacer handles "~/.claude/" (trailing slash) - * but not "~/.claude" (bare, no trailing slash) — so any bare reference in - * update.md would slip through and trigger the installer warning for non-Claude runtimes. - */ - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const UPDATE_MD = path.join(__dirname, '..', 'gsd-core', 'workflows', 'update.md'); - -describe('update.md — no bare ~.claude path references (#2470)', () => { - const content = fs.readFileSync(UPDATE_MD, 'utf-8'); - - test('update.md does not contain bare ~/\\.claude (without trailing slash)', () => { - // This is the exact pattern from the installer's scanForLeakedPaths(): - // /(?:~|\$HOME)\/\.claude\b/g - // The replacer handles ~/\.claude\/ (with trailing slash) but misses bare ~/\.claude - // so we must not have bare references in the source file. - const matches = content.match(/(?:~|\$HOME)\/\.claude(?!\/)/g); - assert.strictEqual( - matches, - null, - `update.md must not contain bare ~/.claude (without trailing slash) — installer scanner flags these as unresolved path refs: ${JSON.stringify(matches)}` - ); - }); -}); diff --git a/tests/bug-2492-context-coverage-gate.test.cjs b/tests/bug-2492-context-coverage-gate.test.cjs deleted file mode 100644 index 58dff07a1..000000000 --- a/tests/bug-2492-context-coverage-gate.test.cjs +++ /dev/null @@ -1,160 +0,0 @@ -// allow-test-rule: source-text-is-the-product -// Workflow .md / agent .md / command .md / reference .md files — their text -// IS what the runtime loads. Testing text content tests the deployed contract. -// Per CONTRIBUTING.md exception matrix. - -/** - * Bug #2492: Add gates to ensure discuss-phase decisions are translated to - * plans (plan-phase, BLOCKING) and verified against shipped artifacts - * (verify-phase, NON-BLOCKING). - * - * These workflow files are loaded as prompts by the corresponding subagents. - * The tests below verify that the prompt text contains the gate steps and - * the config-toggle skip clauses — losing them silently would regress the - * fix. - */ - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const PLAN_PHASE = path.join(__dirname, '..', 'gsd-core', 'workflows', 'plan-phase.md'); -const VERIFY_PHASE = path.join(__dirname, '..', 'gsd-core', 'workflows', 'verify-phase.md'); -const SCHEMA_MANIFEST_JSON = path.join(__dirname, '..', 'gsd-core', 'bin', 'shared', 'config-schema.manifest.json'); - -describe('plan-phase decision-coverage gate (#2492)', () => { - const md = fs.readFileSync(PLAN_PHASE, 'utf-8'); - - test('contains a Decision Coverage Gate step', () => { - assert.ok( - /Decision Coverage Gate/i.test(md), - 'plan-phase.md must define a Decision Coverage Gate step', - ); - }); - - test('invokes the check.decision-coverage-plan handler', () => { - assert.ok( - md.includes('check.decision-coverage-plan'), - 'plan-phase.md must call gsd-sdk query check.decision-coverage-plan', - ); - }); - - test('mentions workflow.context_coverage_gate skip clause', () => { - assert.ok( - md.includes('workflow.context_coverage_gate'), - 'plan-phase.md must reference workflow.context_coverage_gate to allow skipping', - ); - }); - - test('decision gate appears AFTER the existing Requirements Coverage Gate', () => { - // Anchored heading regexes — avoid prose-substring traps (review F8/F9). - const reqIdx = md.search(/^## 13[a-z]?\.\s+Requirements Coverage Gate/m); - const decIdx = md.search(/^## 13[a-z]?\.\s+Decision Coverage Gate/m); - assert.ok(reqIdx !== -1, 'Requirements Coverage Gate heading must exist as ## 13[a-z]?.'); - assert.ok(decIdx !== -1, 'Decision Coverage Gate heading must exist as ## 13[a-z]?.'); - assert.ok(decIdx > reqIdx, 'Decision gate must run after Requirements gate'); - }); - - test('decision gate appears BEFORE plans are committed', () => { - const decIdx = md.search(/^## 13[a-z]?\.\s+Decision Coverage Gate/m); - const commitIdx = md.search(/^## 13[a-z]?\.\s+Commit Plans/m); - assert.ok(decIdx !== -1, 'Decision Coverage Gate heading must exist as ## 13[a-z]?.'); - assert.ok(commitIdx !== -1, 'Commit Plans heading must exist as ## 13[a-z]?.'); - assert.ok(decIdx < commitIdx, 'Decision gate must run before commit so failures block the commit'); - }); - - test('plan-phase Decision Coverage Gate uses CONTEXT_PATH variable defined in INIT extraction (review F1)', () => { - // The CONTEXT_PATH bash variable is defined at Step 4 (`CONTEXT_PATH=$(_gsd_field "$INIT" context_path)`). - // The plan-phase gate snippet must reference the same casing — `${CONTEXT_PATH}` — not `${context_path}`, - // otherwise the BLOCKING gate is invoked with an empty path and silently skips. - const defIdx = md.indexOf('CONTEXT_PATH=$(_gsd_field "$INIT" context_path)'); - assert.ok(defIdx !== -1, 'CONTEXT_PATH must be defined from INIT JSON'); - - const gateIdx = md.indexOf('check.decision-coverage-plan'); - assert.ok(gateIdx !== -1, 'check.decision-coverage-plan invocation must exist'); - - // Slice the surrounding gate snippet (~600 chars) and verify variable casing matches the definition. - const snippet = md.slice(Math.max(0, gateIdx - 200), gateIdx + 400); - assert.ok( - snippet.includes('${CONTEXT_PATH}'), - 'Gate snippet must reference ${CONTEXT_PATH} (uppercase) to match the variable defined in Step 4', - ); - assert.ok( - !snippet.includes('${context_path}'), - 'Gate snippet must NOT reference ${context_path} (lowercase) — that name is undefined in shell scope', - ); - }); - - test('plan-phase blocking gate exits non-zero on failure (review F15)', () => { - // The gate is documented as BLOCKING. To actually block, the shell snippet must - // exit with non-zero status when `passed` is false. Without exit-1 the workflow - // continues silently past the failure. - const gateIdx = md.indexOf('check.decision-coverage-plan'); - assert.ok(gateIdx !== -1); - const snippet = md.slice(gateIdx, gateIdx + 800); - // Accept either an inline `|| exit 1` or a `|| { ...; exit 1; }` group. - const hasJqGuard = - /jq[^\r\n]*\.data\.passed\s*==\s*true/.test(snippet) || - /jq[^\r\n]*\(\.passed\s*\/\/\s*\.data\.passed\)\s*==\s*true/.test(snippet); - const hasExitOne = /\|\|\s*(?:exit\s+1|\{[\s\S]{0,200}?exit\s+1)/.test(snippet); - assert.ok( - hasJqGuard && hasExitOne, - 'plan-phase gate must guard with `jq -e .passed == true || exit 1` (or `|| { ...; exit 1; }`) to actually block', - ); - }); - - test('plan-phase gate accepts top-level .passed field from CLI output (#275)', () => { - const gateIdx = md.indexOf('check.decision-coverage-plan'); - assert.ok(gateIdx !== -1, 'check.decision-coverage-plan invocation must exist'); - const snippet = md.slice(gateIdx, gateIdx + 800); - assert.ok( - /\.(?:passed)\s*==\s*true\s*\|\|\s*\.data\.passed\s*==\s*true/.test(snippet) || - /\(\.passed\s*\/\/\s*\.data\.passed\)\s*==\s*true/.test(snippet), - 'plan-phase gate must explicitly check top-level .passed with a compatibility fallback to .data.passed', - ); - }); -}); - -describe('verify-phase decision-coverage gate (#2492)', () => { - const md = fs.readFileSync(VERIFY_PHASE, 'utf-8'); - - test('contains a verify_decisions step', () => { - assert.ok( - /verify_decisions/.test(md), - 'verify-phase.md must define a verify_decisions step', - ); - }); - - test('invokes the check.decision-coverage-verify handler', () => { - assert.ok( - md.includes('check.decision-coverage-verify'), - 'verify-phase.md must call gsd-sdk query check.decision-coverage-verify', - ); - }); - - test('declares the decision gate as non-blocking / warning only', () => { - const lower = md.toLowerCase(); - assert.ok( - lower.includes('non-blocking') || lower.includes('warning only') || lower.includes('not block'), - 'verify-phase.md must declare the decision gate is non-blocking', - ); - }); - - test('mentions workflow.context_coverage_gate skip clause', () => { - assert.ok( - md.includes('workflow.context_coverage_gate'), - 'verify-phase.md must reference workflow.context_coverage_gate to allow skipping', - ); - }); -}); - -describe('runtime wiring for #2492 gates', () => { - test('schema manifest includes context_coverage_gate', () => { - const manifest = JSON.parse(fs.readFileSync(SCHEMA_MANIFEST_JSON, 'utf-8')); - assert.ok( - manifest.validKeys.includes('workflow.context_coverage_gate'), - 'workflow.context_coverage_gate must be present in config-schema manifest', - ); - }); -}); diff --git a/tests/bug-2501-resurrection-detection.test.cjs b/tests/bug-2501-resurrection-detection.test.cjs deleted file mode 100644 index f31d11e2e..000000000 --- a/tests/bug-2501-resurrection-detection.test.cjs +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Tests for bug #2501: resurrection-detection block in execute-phase.md must - * check git history before deleting new .planning/ files. - * - * Root cause: the original logic deleted ANY .planning/ file that was absent - * from PRE_MERGE_FILES, which includes brand-new files (e.g. SUMMARY.md) - * that the executor just created. A true "resurrection" is a file that was - * previously tracked on main, deliberately deleted, and then re-introduced by - * a worktree merge. Detecting that requires a git history check, not just a - * pre-merge tree membership check. - * - * After #3797: execute-phase.md delegates worktree cleanup to the SDK's - * worktree.cleanup-wave command. Resurrection detection is handled internally - * by the SDK. The inline WAS_DELETED shell check has been removed from the - * workflow — it was part of the SDK-absence fallback which is no longer needed - * since the preflight block exits if neither local nor global SDK is available. - */ - -'use strict'; - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const EXECUTE_PHASE = path.join( - __dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md' -); - -describe('execute-phase.md — resurrection-detection guard (#2501)', () => { - let content; - - // Load once; each test reads from the cached string. - test('file is readable', () => { - content = fs.readFileSync(EXECUTE_PHASE, 'utf-8'); - assert.ok(content.length > 0, 'execute-phase.md must not be empty'); - }); - - test('cleanup delegates to SDK (handles resurrection detection internally)', () => { - if (!content) content = fs.readFileSync(EXECUTE_PHASE, 'utf-8'); - // After #3797: execute-phase.md delegates to worktree.cleanup-wave, which - // handles pre-merge deletion checks internally. The SDK checks diff --diff-filter=D - // before merging, blocking branches that contain file deletions (#2384/#2501). - assert.ok( - content.includes('worktree.cleanup-wave'), - 'execute-phase.md must delegate to worktree.cleanup-wave (#2501/#3797)', - ); - }); - - test('execute-phase does not use the buggy PRE_MERGE_FILES form', () => { - if (!content) content = fs.readFileSync(EXECUTE_PHASE, 'utf-8'); - // The buggy pattern from before #2501 — deletion conditioned on absence - // from PRE_MERGE_FILES snapshot. Must remain absent. - const hasBuggyGuard = - content.includes('PRE_MERGE_FILES') && - /if\s*!\s*echo\s*"\$PRE_MERGE_FILES"\s*\|\s*grep\s+-qxF\s*"\$RESURRECTED"/.test(content); - assert.ok( - !hasBuggyGuard, - 'execute-phase.md must NOT delete files based on the PRE_MERGE_FILES snapshot grep (inverted guard bug #2501)', - ); - }); -}); diff --git a/tests/bug-2502-insert-phase-state-update.test.cjs b/tests/bug-2502-insert-phase-state-update.test.cjs deleted file mode 100644 index 33b4d7a4c..000000000 --- a/tests/bug-2502-insert-phase-state-update.test.cjs +++ /dev/null @@ -1,67 +0,0 @@ -// allow-test-rule: source-text-is-the-product -// Workflow .md / agent .md / command .md / reference .md files — their text -// IS what the runtime loads. Testing text content tests the deployed contract. -// Per CONTRIBUTING.md exception matrix. - -/** - * Regression test for #2502: insert-phase does not update STATE.md's - * next-phase recommendation after inserting a decimal phase. - * - * Root cause: insert-phase.md's update_project_state step only added a - * "Roadmap Evolution" note to STATE.md, but never updated the "Current Phase" - * / next-run recommendation to point at the newly inserted phase. - * - * Fix: insert-phase.md must include a step that updates STATE.md's next-phase - * pointer (current_phase / next recommended run) to the newly inserted phase. - */ - -'use strict'; - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const INSERT_PHASE_PATH = path.join( - __dirname, '..', 'gsd-core', 'workflows', 'insert-phase.md' -); - -describe('bug-2502: insert-phase must update STATE.md next-phase recommendation', () => { - test('insert-phase.md exists', () => { - assert.ok(fs.existsSync(INSERT_PHASE_PATH), 'insert-phase.md should exist'); - }); - - test('insert-phase.md contains a STATE.md next-phase update instruction', () => { - const content = fs.readFileSync(INSERT_PHASE_PATH, 'utf-8'); - - // Must reference STATE.md and the concept of updating the next/current phase pointer - const mentionsStateUpdate = ( - /STATE\.md.{0,200}(next.phase|current.phase|next.run|recommendation)/is.test(content) || - /(next.phase|current.phase|next.run|recommendation).{0,200}STATE\.md/is.test(content) - ); - - assert.ok( - mentionsStateUpdate, - 'insert-phase.md must instruct updating STATE.md\'s next-phase recommendation to point to the newly inserted phase' - ); - }); - - test('insert-phase.md update_project_state step covers next-phase pointer', () => { - const content = fs.readFileSync(INSERT_PHASE_PATH, 'utf-8'); - - const stepMatch = content.match(/([\s\S]*?)<\/step>/i); - assert.ok(stepMatch, 'insert-phase.md must contain update_project_state step'); - const stepContent = stepMatch[1]; - - const hasNextPhasePointerUpdate = ( - /\bcurrent[_ -]?phase\b/i.test(stepContent) || - /\bnext[_ -]?phase\b/i.test(stepContent) || - /\bnext recommended run\b/i.test(stepContent) - ); - - assert.ok( - hasNextPhasePointerUpdate, - 'insert-phase.md update_project_state step must update STATE.md\'s next-phase pointer (current_phase) to the inserted decimal phase' - ); - }); -}); diff --git a/tests/bug-2504-uat-foundation-phases.test.cjs b/tests/bug-2504-uat-foundation-phases.test.cjs deleted file mode 100644 index d5afccf0c..000000000 --- a/tests/bug-2504-uat-foundation-phases.test.cjs +++ /dev/null @@ -1,143 +0,0 @@ -/** - * Regression test for bug #2504 - * - * When UAT testing is mandated and a phase has no user-facing elements - * (e.g., code foundations, database schema, internal APIs), the agent - * invented artificial UAT steps — things like "manually run git commits", - * "manually invoke methods", "manually check database state" — and left - * work half-finished specifically to create things for a human to do. - * - * Fix: The verify-phase workflow's identify_human_verification step must - * explicitly handle phases with no user-facing elements by auto-passing UAT - * with a logged rationale instead of inventing manual steps. - */ - -'use strict'; - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const VERIFY_PHASE_PATH = path.join( - __dirname, '..', 'gsd-core', 'workflows', 'verify-phase.md' -); - -/** - * Extract a named section from a markdown/workflow document. - * Returns the text from `heading` up to (but not including) the next `## ` heading, - * or to end-of-file if no subsequent heading exists. - */ -function extractSection(content, heading) { - const start = content.indexOf(heading); - if (start === -1) return ''; - const nextHeading = content.indexOf('\n## ', start + 1); - return nextHeading === -1 ? content.slice(start) : content.slice(start, nextHeading); -} - -describe('bug #2504: UAT auto-pass for foundation/infrastructure phases', () => { - test('verify-phase workflow file exists', () => { - assert.ok( - fs.existsSync(VERIFY_PHASE_PATH), - 'gsd-core/workflows/verify-phase.md should exist' - ); - }); - - test('identify_human_verification step handles phases with no user-facing elements', () => { - const content = fs.readFileSync(VERIFY_PHASE_PATH, 'utf-8'); - const section = extractSection(content, 'identify_human_verification'); - // The step must explicitly call out the infrastructure/foundation case - const hasInfrastructureHandling = - section.includes('infrastructure') || - section.includes('foundation') || - section.includes('no user-facing') || - section.includes('no user facing') || - section.includes('internal API') || - section.includes('internal APIs') || - section.includes('database schema') || - section.includes('code foundation'); - - assert.ok( - hasInfrastructureHandling, - 'verify-phase.md identify_human_verification step must explicitly handle ' + - 'infrastructure/foundation phases that have no user-facing elements. Without ' + - 'this, agents invent artificial manual steps to satisfy UAT requirements ' + - '(root cause of #2504).' - ); - }); - - test('workflow includes auto-pass or skip UAT language for non-user-facing phases', () => { - const content = fs.readFileSync(VERIFY_PHASE_PATH, 'utf-8'); - const section = extractSection(content, 'identify_human_verification'); - const hasAutoPass = - section.includes('auto-pass') || - section.includes('auto pass') || - section.includes('automatically pass') || - section.includes('skip UAT') || - section.includes('skip the UAT') || - section.includes('UAT does not apply') || - section.includes('UAT not applicable') || - section.includes('no UAT required'); - - assert.ok( - hasAutoPass, - 'verify-phase.md identify_human_verification step must contain language about ' + - 'auto-passing or skipping UAT for phases without user-facing elements. Agents ' + - 'must not invent manual steps when there is nothing user-facing to test ' + - '(root cause of #2504).' - ); - }); - - test('workflow prohibits inventing artificial manual steps for infrastructure phases', () => { - const content = fs.readFileSync(VERIFY_PHASE_PATH, 'utf-8'); - const section = extractSection(content, 'identify_human_verification'); - // The workflow must tell the agent NOT to invent steps when there's nothing to test. - // Look for explicit prohibition or the inverse: "do not invent" or "must not create" - // or equivalent framing like "only require human testing when..." - const hasProhibition = - section.includes('do not invent') || - section.includes('must not invent') || - section.includes('never invent') || - section.includes('Do not invent') || - section.includes('Must not invent') || - section.includes('Never invent') || - section.includes('only require human') || - section.includes('only add human') || - (section.includes('only flag') && section.includes('user-facing')) || - // Or via "N/A" framing - (section.includes('N/A') && ( - section.includes('infrastructure') || - section.includes('foundation') || - section.includes('no user-facing') - )); - - assert.ok( - hasProhibition, - 'verify-phase.md identify_human_verification step must explicitly prohibit ' + - 'inventing artificial manual UAT steps for infrastructure phases. The current ' + - 'wording causes agents to create fake "manually run git commits" steps to ' + - 'satisfy UAT mandates (root cause of #2504).' - ); - }); - - test('workflow includes a concept of N/A or not-applicable UAT state', () => { - const content = fs.readFileSync(VERIFY_PHASE_PATH, 'utf-8'); - const section = extractSection(content, 'identify_human_verification'); - const hasNaState = - section.includes('N/A') || - section.includes('not applicable') || - section.includes('not_applicable') || - section.includes('no_uat') || - section.includes('uat_not_applicable') || - section.includes('infrastructure phase') || - section.includes('foundation phase'); - - assert.ok( - hasNaState, - 'verify-phase.md identify_human_verification step must include some concept of ' + - 'a "not applicable" or N/A UAT state for phases with no user-facing elements. ' + - 'This prevents agents from blocking phase completion on invented manual steps ' + - '(root cause of #2504).' - ); - }); -}); diff --git a/tests/bug-2506-settings-profile-nonclaude-warning.test.cjs b/tests/bug-2506-settings-profile-nonclaude-warning.test.cjs deleted file mode 100644 index d9fd7f688..000000000 --- a/tests/bug-2506-settings-profile-nonclaude-warning.test.cjs +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Regression test for bug #2506 - * - * /gsd-settings presents Quality/Balanced/Budget model profiles without any - * warning that on non-Claude runtimes (Codex, Gemini CLI, etc.) these profiles - * select Claude model tiers and have no effect on actual agent model selection. - * - * Fix: settings.md must include a non-Claude runtime note instructing users to - * use "Inherit" or configure model_overrides manually, and the Inherit option - * description must explicitly call out non-Claude runtimes. - * - * Closes: #2506 - */ - -'use strict'; - -const { describe, test, before } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const SETTINGS_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'settings.md'); - -describe('bug #2506: settings.md non-Claude runtime warning for model profiles', () => { - let content; - - before(() => { - content = fs.readFileSync(SETTINGS_PATH, 'utf-8'); - }); - - test('settings.md contains a non-Claude runtime note for model profiles', () => { - assert.ok( - content.includes('non-Claude runtime') || content.includes('non-Claude runtimes'), - 'settings.md must include a note about non-Claude runtimes and model profiles' - ); - }); - - test('non-Claude note explains profiles are no-ops without model_overrides', () => { - assert.ok( - content.includes('model_overrides') || content.includes('no effect'), - 'note must explain profiles have no effect on non-Claude runtimes without model_overrides' - ); - }); - - test('Inherit option description explicitly mentions non-Claude runtimes', () => { - // The Inherit option in AskUserQuestion must call out non-Claude runtimes - const inheritOptionMatch = content.match(/label:\s*"Inherit"[^}]*description:\s*"([^"]+)"/s); - assert.ok(inheritOptionMatch, 'Inherit option with label/description must exist in settings.md'); - const desc = inheritOptionMatch[1]; - assert.ok( - desc.includes('non-Claude') || desc.includes('Codex') || desc.includes('Gemini'), - `Inherit option description must mention non-Claude runtimes; got: "${desc}"` - ); - }); -}); diff --git a/tests/bug-2516-inherit-model-execute-phase.test.cjs b/tests/bug-2516-inherit-model-execute-phase.test.cjs deleted file mode 100644 index 699358976..000000000 --- a/tests/bug-2516-inherit-model-execute-phase.test.cjs +++ /dev/null @@ -1,119 +0,0 @@ -// allow-test-rule: source-text-is-the-product -// Workflow .md / agent .md / command .md / reference .md files — their text -// IS what the runtime loads. Testing text content tests the deployed contract. -// Per CONTRIBUTING.md exception matrix. - -/** - * Regression test for bug #2516 - * - * When `.planning/config.json` has `model_profile: "inherit"`, the - * `init.execute-phase` query returns `executor_model: "inherit"`. The - * execute-phase workflow was passing this literal string directly to the - * Task tool via `model="{executor_model}"`, causing Task to fall back to - * its default model instead of inheriting the orchestrator model. - * - * Fix: the workflow must document that when `executor_model` is `"inherit"`, - * the `model=` parameter must be OMITTED from Task() calls entirely. - * Omitting `model=` causes Claude Code to inherit the current orchestrator - * model automatically. - */ - -'use strict'; - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const WORKFLOW_PATH = path.join( - __dirname, - '..', - 'gsd-core', - 'workflows', - 'execute-phase.md' -); - -describe('bug #2516: executor_model "inherit" must not be passed literally to Task()', () => { - test('workflow file exists', () => { - assert.ok(fs.existsSync(WORKFLOW_PATH), 'gsd-core/workflows/execute-phase.md should exist'); - }); - - test('workflow contains instructions for handling the "inherit" case', () => { - assert.ok(fs.existsSync(WORKFLOW_PATH), 'gsd-core/workflows/execute-phase.md should exist'); - const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8'); - const hasInheritInstruction = - content.includes('"inherit"') && - (content.includes('omit') || content.includes('Omit') || content.includes('omitting') || content.includes('Omitting')); - assert.ok( - hasInheritInstruction, - 'execute-phase.md must document that when executor_model is "inherit", ' + - 'the model= parameter must be omitted from Task() calls. ' + - 'Found "inherit" mention: ' + content.includes('"inherit"') + '. ' + - 'Found omit mention: ' + (content.includes('omit') || content.includes('Omit')) - ); - }); - - test('workflow does not instruct passing model="inherit" literally to Task', () => { - assert.ok(fs.existsSync(WORKFLOW_PATH), 'gsd-core/workflows/execute-phase.md should exist'); - const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8'); - // The workflow must not have an unconditional model="{executor_model}" template - // that would pass "inherit" through. It should document conditional logic. - const hasConditionalModelParam = - content.includes('inherit') && - ( - content.includes('Only set `model=`') || - content.includes('only set `model=`') || - content.includes('Only set model=') || - content.includes('omit the `model=`') || - content.includes('omit the model=') || - content.includes('omit `model=`') || - content.includes('omit model=') - ); - const lines = content.split(/\r?\n/); - const hasLiteralInheritInTask = lines.some(line => { - if (!/model\s*=\s*["']inherit["']/.test(line)) return false; - // Exclude instructional/explanatory lines that document what NOT to do - return !/\b(not|NOT|don'?t|do not|DO NOT|never|NEVER)\b/.test(line); - }); - assert.ok( - !hasLiteralInheritInTask, - 'execute-phase workflow must not pass literal "inherit" string to Task() model parameter' - ); - assert.ok( - hasConditionalModelParam && !hasLiteralInheritInTask, - 'execute-phase.md must conditionally omit model= when executor_model is "inherit", never pass it literally. ' + - 'The unconditional model="{executor_model}" template would pass the literal ' + - 'string "inherit" to Task(), which falls back to the default model instead ' + - 'of the orchestrator model (root cause of #2516).' - ); - // Guard against a future contributor adding an unconditional model="{executor_model}" - // template alongside the conditional docs — that would pass "inherit" literally to Task(). - const hasUnsafeTemplate = lines.some(line => { - if (!/model\s*=\s*['"]\{executor_model\}['"]/.test(line)) return false; - return !/\b(not|NOT|do not|DO NOT|don'?t|never|NEVER|omit)\b/i.test(line); - }); - assert.ok(!hasUnsafeTemplate, - 'execute-phase.md must not contain an unconditional model="{executor_model}" template — ' + - 'it would pass "inherit" literally to Task() when executor_model is "inherit"' - ); - }); - - test('workflow documents that omitting model= causes inheritance from orchestrator', () => { - assert.ok(fs.existsSync(WORKFLOW_PATH), 'gsd-core/workflows/execute-phase.md should exist'); - const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8'); - const hasInheritanceExplanation = - content.includes('inherit') && - ( - content.includes('orchestrator model') || - content.includes('orchestrator\'s model') || - content.includes('inherits the') || - content.includes('inherit the current') - ); - assert.ok( - hasInheritanceExplanation, - 'execute-phase.md must explain that omitting model= causes Claude Code to ' + - 'inherit the current orchestrator model — this is the mechanism that makes ' + - '"inherit" work correctly.' - ); - }); -}); diff --git a/tests/bug-2523-quick-deferred-items.test.cjs b/tests/bug-2523-quick-deferred-items.test.cjs deleted file mode 100644 index f33f115ee..000000000 --- a/tests/bug-2523-quick-deferred-items.test.cjs +++ /dev/null @@ -1,56 +0,0 @@ -/** - * Regression test for bug #2523 - * - * workflows/quick.md Step 8 ("Build file list") listed PLAN.md, SUMMARY.md, - * STATE.md, and mode-conditional CONTEXT.md / RESEARCH.md / VERIFICATION.md — - * but omitted deferred-items.md. When an executor logs out-of-scope findings - * to ${QUICK_DIR}/${quick_id}-deferred-items.md during task execution, that - * file was left untracked after the final commit even with commit_docs: true. - * - * Fix: add a file-existence-gated entry for deferred-items.md to Step 8. - */ - -'use strict'; - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const WORKFLOW_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'quick.md'); - -describe('bug #2523: quick-task final commit includes deferred-items.md', () => { - const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8'); - - test('workflow file exists', () => { - assert.ok(fs.existsSync(WORKFLOW_PATH), 'gsd-core/workflows/quick.md must exist'); - }); - - test('Step 8 file list references deferred-items.md', () => { - const step8Idx = content.indexOf('Step 8: Final commit'); - assert.notEqual(step8Idx, -1, 'Step 8 section must exist in quick.md'); - - const step8Section = content.slice(step8Idx, step8Idx + 2000); - assert.ok( - step8Section.includes('deferred-items.md'), - 'Step 8 file list must include deferred-items.md. ' + - 'Without this, any out-of-scope findings logged by the executor to ' + - '${QUICK_DIR}/${quick_id}-deferred-items.md are left untracked after commit (bug #2523).' - ); - }); - - test('deferred-items.md entry is conditional on file existence', () => { - const deferredIdx = content.indexOf('deferred-items.md'); - assert.notEqual(deferredIdx, -1, 'deferred-items.md must be mentioned in the workflow'); - - const surroundingContext = content.slice(Math.max(0, deferredIdx - 200), deferredIdx + 200); - const isConditional = - surroundingContext.toLowerCase().includes('exist') || - surroundingContext.toLowerCase().includes('if '); - assert.ok( - isConditional, - 'The deferred-items.md entry must be gated on file existence (not a mode flag). ' + - 'deferred-items.md can be created in any quick-task run, regardless of mode.' - ); - }); -}); diff --git a/tests/bug-2549-2550-2552-discuss-phase-context.test.cjs b/tests/bug-2549-2550-2552-discuss-phase-context.test.cjs deleted file mode 100644 index f3e9f855b..000000000 --- a/tests/bug-2549-2550-2552-discuss-phase-context.test.cjs +++ /dev/null @@ -1,105 +0,0 @@ -// allow-test-rule: source-text-is-the-product -// Workflow .md / agent .md / command .md / reference .md files — their text -// IS what the runtime loads. Testing text content tests the deployed contract. -// Per CONTRIBUTING.md exception matrix. -'use strict'; - - -/** - * Bugs #2549, #2550, #2552: discuss-phase context bloat and cache invalidation. - * - * #2549: load_prior_context must cap prior CONTEXT.md reads (was O(phases)) - * #2550: scout_codebase must select maps by phase type (was always all 7) - * #2552: scout_codebase must not instruct split reads of the same file - */ - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const DISCUSS_PHASE = path.join( - __dirname, '..', 'gsd-core', 'workflows', 'discuss-phase.md', -); -// After the discuss-phase progressive-disclosure split (#717), the scout_codebase phase-type -// table and split-reads warning live in references/scout-codebase.md. -const SCOUT_REF = path.join( - __dirname, '..', 'gsd-core', 'references', 'scout-codebase.md', -); - -function readDiscussContext() { - // Both files are required after the discuss-phase/modes split — fail loudly if either is missing - // rather than silently weakening the regression coverage. - for (const p of [DISCUSS_PHASE, SCOUT_REF]) { - assert.ok(fs.existsSync(p), `Required discuss-phase context source missing: ${p}`); - } - return [DISCUSS_PHASE, SCOUT_REF].map(p => fs.readFileSync(p, 'utf-8')).join('\n'); -} - -describe('discuss-phase context fixes (#2549, #2550, #2552)', () => { - let src; - test('discuss-phase.md source exists', () => { - assert.ok(fs.existsSync(DISCUSS_PHASE), 'discuss-phase.md must exist'); - assert.ok( - fs.existsSync(SCOUT_REF), - 'references/scout-codebase.md must exist after the discuss-phase/modes progressive-disclosure split', - ); - src = readDiscussContext(); - }); - - // ─── #2549: load_prior_context cap ────────────────────────────────────── - test('#2549: load_prior_context must NOT instruct reading ALL prior CONTEXT.md files', () => { - if (!src) src = readDiscussContext(); - assert.ok( - !src.includes('For each CONTEXT.md where phase number < current phase'), - 'load_prior_context must not unboundedly read all prior CONTEXT.md files', - ); - }); - - test('#2549: load_prior_context must reference a bounded read (3 phases or DECISIONS-INDEX)', () => { - // Read ONLY the parent file — `src.includes('3')` against the - // concatenated source can be satisfied by unrelated occurrences of "3" - // in scout-codebase.md (e.g., "3-5 most relevant files"), masking a - // regression where the parent drops the bounded-read instruction. - const parent = fs.readFileSync(DISCUSS_PHASE, 'utf-8'); - const hasBound = /\b(?:most recent|latest|last|up to)\s+3\b[\s\S]{0,160}\bprior CONTEXT\.md\b/i.test(parent); - const hasIndex = parent.includes('DECISIONS-INDEX.md'); - assert.ok( - hasBound || hasIndex, - 'load_prior_context must reference a bounded read (e.g., most recent 3 phases) or DECISIONS-INDEX.md', - ); - }); - - // ─── #2550: scout_codebase phase-type selection ────────────────────────── - test('#2550: scout_codebase must not instruct reading all 7 codebase maps', () => { - if (!src) src = readDiscussContext(); - assert.ok( - !src.includes('Read the most relevant ones (CONVENTIONS.md, STRUCTURE.md, STACK.md based on phase type)'), - 'scout_codebase must not use the old vague "most relevant" instruction without a selection table', - ); - }); - - test('#2550: scout_codebase must include a phase-type-to-maps selection table', () => { - if (!src) src = readDiscussContext(); - // The table maps phase types to specific map selections - assert.ok( - src.includes('Phase type') && src.includes('Read these maps'), - 'scout_codebase must include a phase-type to map-selection table', - ); - // Key phase types must be covered - assert.ok(src.includes('UI') || src.includes('frontend'), 'Table must cover UI/frontend phases'); - assert.ok(src.includes('Backend') || src.includes('API'), 'Table must cover backend phases'); - assert.ok(src.includes('Testing'), 'Table must cover testing phases'); - assert.ok(src.includes('Mixed'), 'Table must have a fallback for mixed/unclear phases'); - }); - - // ─── #2552: no split reads ─────────────────────────────────────────────── - test('#2552: scout_codebase must explicitly prohibit split reads of the same file', () => { - if (!src) src = readDiscussContext(); - const prohibitsSplit = src.includes('split reads') || src.includes('split read'); - assert.ok( - prohibitsSplit, - 'scout_codebase must explicitly warn against split reads (same file, two offsets) that break prompt cache', - ); - }); -}); diff --git a/tests/bug-2838-summary-rescue-gitignored-planning.test.cjs b/tests/bug-2838-summary-rescue-gitignored-planning.test.cjs deleted file mode 100644 index 74b1f4dd0..000000000 --- a/tests/bug-2838-summary-rescue-gitignored-planning.test.cjs +++ /dev/null @@ -1,117 +0,0 @@ -/** - * Regression tests for #2838: SUMMARY rescue silently fails when .planning/ - * is gitignored. - * - * After #3797: execute-phase.md and quick.md delegate worktree cleanup to the - * SDK's worktree.cleanup-wave command. The SDK's executeWorktreeWaveCleanupPlan - * handles SUMMARY rescue internally using a filesystem-level find+cp approach - * (bypassing gitignore) rather than the old git ls-files --exclude-standard - * form that silently dropped gitignored files. - * - * The inline "Safety net" shell rescue block that was previously in both - * workflow files has been removed — it was part of the SDK-absence fallback - * which is now dead code since preflight exits if neither local nor global SDK - * is available. - * - * This test file verifies that both workflows correctly delegate to the SDK - * for SUMMARY rescue, and that neither workflow retains the broken inline form. - */ - -'use strict'; - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const REPO_ROOT = path.join(__dirname, '..'); -const EXECUTE_PHASE_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'execute-phase.md'); -const QUICK_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'quick.md'); - -/** - * Parse a workflow markdown file into a structured contract object. - * Returns typed boolean fields so tests assert on structure, not raw text. - */ -function parseWorkflowContract(filePath) { - const lines = fs.readFileSync(filePath, 'utf-8').split(/\r?\n/); - return { - // Non-empty check - nonEmpty: lines.length > 0 && lines.some(l => l.length > 0), - // Does the workflow delegate SUMMARY rescue to worktree.cleanup-wave? - delegatesToCleanupWave: lines.some(l => l.includes('worktree.cleanup-wave')), - // Does the cleanup-wave invocation use || exit 1 (fail-closed)? - cleanupWaveFailClosed: lines.some( - l => /gsd_run query worktree\.cleanup-wave.*\|\| exit 1/.test(l), - ), - // Does the workflow still contain the broken ls-files --exclude-standard rescue form? - hasBrokenLsFilesForm: lines.some( - l => l.includes('ls-files --modified --others --exclude-standard'), - ), - }; -} - -const executePhaseContract = parseWorkflowContract(EXECUTE_PHASE_PATH); -const quickContract = parseWorkflowContract(QUICK_PATH); - -describe('bug-2838: SUMMARY rescue delegates to SDK (worktree.cleanup-wave)', () => { - - test('execute-phase.md is readable', () => { - assert.ok(executePhaseContract.nonEmpty, 'execute-phase.md must not be empty'); - }); - - test('quick.md is readable', () => { - assert.ok(quickContract.nonEmpty, 'quick.md must not be empty'); - }); - - test('execute-phase.md delegates SUMMARY rescue to SDK (worktree.cleanup-wave)', () => { - // After #3797: worktree.cleanup-wave handles SUMMARY rescue via find+cp - // (bypasses gitignore, fixing the #2838 bug). The workflow delegates to the - // SDK rather than implementing rescue inline. - assert.ok( - executePhaseContract.delegatesToCleanupWave, - 'execute-phase.md must delegate to worktree.cleanup-wave for SUMMARY rescue (#2838/#3797)', - ); - }); - - test('quick.md delegates SUMMARY rescue to SDK (worktree.cleanup-wave)', () => { - // After #3797: worktree.cleanup-wave handles SUMMARY rescue via find+cp - // (bypasses gitignore, fixing the #2838 bug). - assert.ok( - quickContract.delegatesToCleanupWave, - 'quick.md must delegate to worktree.cleanup-wave for SUMMARY rescue (#2838/#3797)', - ); - }); - - test('execute-phase.md does not retain broken git ls-files --exclude-standard rescue form (#2838)', () => { - // The broken form used --exclude-standard which silently filtered out - // gitignored .planning/ files — the root cause of #2838. - assert.ok( - !executePhaseContract.hasBrokenLsFilesForm, - 'execute-phase.md must not use ls-files --exclude-standard for SUMMARY rescue (broken for gitignored .planning/)', - ); - }); - - test('quick.md does not retain broken git ls-files --exclude-standard rescue form (#2838)', () => { - assert.ok( - !quickContract.hasBrokenLsFilesForm, - 'quick.md must not use ls-files --exclude-standard for SUMMARY rescue (broken for gitignored .planning/)', - ); - }); - - test('execute-phase.md cleanup-wave uses || exit 1 (fail-closed so rescue errors surface)', () => { - // If the SDK's rescue fails (e.g. filesystem error), || exit 1 surfaces - // the failure to the orchestrator rather than silently continuing and - // losing the SUMMARY. - assert.ok( - executePhaseContract.cleanupWaveFailClosed, - 'execute-phase.md cleanup-wave must use || exit 1 so SUMMARY rescue failures surface (#2838/#3797)', - ); - }); - - test('quick.md cleanup-wave uses || exit 1 (fail-closed so rescue errors surface)', () => { - assert.ok( - quickContract.cleanupWaveFailClosed, - 'quick.md cleanup-wave must use || exit 1 so SUMMARY rescue failures surface (#2838/#3797)', - ); - }); -}); diff --git a/tests/bug-2912-progress-context-authority.test.cjs b/tests/bug-2912-progress-context-authority.test.cjs deleted file mode 100644 index dfd4a079b..000000000 --- a/tests/bug-2912-progress-context-authority.test.cjs +++ /dev/null @@ -1,154 +0,0 @@ -/** - * Tests for issue #2912 — /gsd-progress can use stale CLAUDE.md project block - * instead of GSD tracking files as authoritative source. - * - * Fix: the `report` step in gsd-core/workflows/progress.md must contain - * an explicit "context authority" directive establishing PROJECT.md, STATE.md, - * and ROADMAP.md as the authoritative sources for the progress report, and - * forbidding the use of CLAUDE.md `## Project` blocks as a source for any - * report field. - * - * These tests parse the workflow markdown structurally (locate the - * ... block, then locate the blockquote-style - * directive inside it). They do NOT use `.includes()` over the whole file. - */ - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const WORKFLOW_PATH = path.join( - __dirname, - '..', - 'gsd-core', - 'workflows', - 'progress.md' -); - -/** Extract the body of a ... block by parsing tags. */ -function extractStep(workflow, stepName) { - const openTag = ``; - const start = workflow.indexOf(openTag); - if (start === -1) return null; - const bodyStart = start + openTag.length; - // Find the matching — workflow steps in this file do not nest. - const end = workflow.indexOf('', bodyStart); - if (end === -1) return null; - return workflow.slice(bodyStart, end); -} - -/** - * Extract contiguous markdown blockquote blocks from a chunk of markdown. - * A blockquote is a run of consecutive lines starting with '>' (after any - * leading whitespace). Returns the joined text of each blockquote with the - * leading '>' markers stripped. - */ -function extractBlockquotes(md) { - const lines = md.split(/\r?\n/); - const blocks = []; - let current = null; - for (const line of lines) { - const m = line.match(/^\s*>\s?(.*)$/); - if (m) { - if (current === null) current = []; - current.push(m[1]); - } else { - if (current !== null) { - blocks.push(current.join('\n')); - current = null; - } - } - } - if (current !== null) blocks.push(current.join('\n')); - return blocks; -} - -describe('#2912: progress report step has explicit context-authority directive', () => { - test('progress.md workflow file exists and is readable', () => { - const stat = fs.statSync(WORKFLOW_PATH); - assert.ok(stat.isFile(), 'workflow file should exist'); - }); - - test('progress.md has a section', () => { - const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf8'); - const reportStep = extractStep(workflow, 'report'); - assert.ok(reportStep, 'workflow should contain a report step'); - assert.ok(reportStep.length > 0, 'report step body should not be empty'); - }); - - test('report step contains a blockquote directive about context authority', () => { - const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf8'); - const reportStep = extractStep(workflow, 'report'); - assert.ok(reportStep, 'report step must be present'); - - const blockquotes = extractBlockquotes(reportStep); - assert.ok( - blockquotes.length > 0, - 'report step should contain at least one blockquote (the context-authority directive)' - ); - - const authorityBlock = blockquotes.find((b) => /context\s+authority/i.test(b)); - assert.ok( - authorityBlock, - 'report step should contain a blockquote whose text includes "Context authority"' - ); - }); - - test('context-authority directive names PROJECT.md, STATE.md, and ROADMAP.md as authoritative', () => { - const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf8'); - const reportStep = extractStep(workflow, 'report'); - assert.ok(reportStep, 'report step must exist'); - const blockquotes = extractBlockquotes(reportStep); - const authorityBlock = blockquotes.find((b) => /context\s+authority/i.test(b)); - assert.ok(authorityBlock, 'authority blockquote must exist'); - - assert.match( - authorityBlock, - /PROJECT\.md/, - 'directive should name PROJECT.md as authoritative' - ); - assert.match( - authorityBlock, - /STATE\.md/, - 'directive should name STATE.md as authoritative' - ); - assert.match( - authorityBlock, - /ROADMAP\.md/, - 'directive should name ROADMAP.md as authoritative' - ); - assert.match( - authorityBlock, - /authoritative/i, - 'directive should describe these files as authoritative' - ); - }); - - test('context-authority directive forbids using CLAUDE.md project block as a source', () => { - const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf8'); - const reportStep = extractStep(workflow, 'report'); - assert.ok(reportStep, 'report step must exist'); - const blockquotes = extractBlockquotes(reportStep); - const authorityBlock = blockquotes.find((b) => /context\s+authority/i.test(b)); - assert.ok(authorityBlock, 'authority blockquote must exist'); - - assert.match( - authorityBlock, - /CLAUDE\.md/, - 'directive should explicitly mention CLAUDE.md' - ); - // Must explicitly forbid CLAUDE.md as a source — look for a NOT/do not directive - // co-located with the CLAUDE.md mention. - assert.match( - authorityBlock, - /(do\s+NOT|do\s+not|must\s+NOT|must\s+not|never)/i, - 'directive should contain an explicit prohibition (do NOT / must not / never)' - ); - assert.match( - authorityBlock, - /## Project/, - 'directive should call out the CLAUDE.md "## Project" block specifically' - ); - }); -}); diff --git a/tests/bug-2950-stale-command-refs.test.cjs b/tests/bug-2950-stale-command-refs.test.cjs deleted file mode 100644 index 5b5d71704..000000000 --- a/tests/bug-2950-stale-command-refs.test.cjs +++ /dev/null @@ -1,144 +0,0 @@ -/** - * Bug #2950: Stale deleted command references in workflow files - * - * Multiple workflow files referenced command names removed in #2790 - * (gsd-add-phase, gsd-insert-phase, gsd-remove-phase, gsd-add-todo, - * gsd-set-profile, gsd-settings-integrations, gsd-settings-advanced, - * gsd-spike-wrap-up, gsd-sketch-wrap-up, gsd-code-review-fix). - * - * Fix: Update every occurrence to the new consolidated forms: - * /gsd:phase (no flag | --insert | --remove) - * /gsd:capture - * /gsd:config (--profile | --integrations | --advanced) - * /gsd:spike --wrap-up - * /gsd:sketch --wrap-up - * /gsd:code-review --fix - */ - -'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 WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows'); - -function read(filename) { - return fs.readFileSync(path.join(WORKFLOWS_DIR, filename), 'utf-8'); -} - -// Deleted command names that must not appear anywhere in the fixed files. -const DELETED_COMMANDS = [ - '/gsd-add-phase', - '/gsd-insert-phase', - '/gsd-remove-phase', - '/gsd-add-todo', - '/gsd-set-profile', - '/gsd-settings-integrations', - '/gsd-settings-advanced', - '/gsd-spike-wrap-up', - '/gsd-sketch-wrap-up', - '/gsd-code-review-fix', -]; - -// Per-file assertions: [file, deletedCmd, newForm] -const FILE_ASSERTIONS = [ - // help.md → moved to help/modes/full.md in #3039 tiered-help refactor - ['help/modes/full.md', '/gsd-add-phase', '/gsd:phase "Add admin dashboard"'], - ['help/modes/full.md', '/gsd-insert-phase', '/gsd:phase --insert 7 "Fix critical auth bug"'], - ['help/modes/full.md', '/gsd-remove-phase', '/gsd:phase --remove 17'], - ['help/modes/full.md', '/gsd-spike-wrap-up', '/gsd:spike --wrap-up'], - ['help/modes/full.md', '/gsd-sketch-wrap-up', '/gsd:sketch --wrap-up'], - ['help/modes/full.md', '/gsd-add-todo', '/gsd:capture'], - ['help/modes/full.md', '/gsd-set-profile', '/gsd:config --profile budget'], - - // do.md - ['do.md', '/gsd-spike-wrap-up', '/gsd:spike --wrap-up'], - ['do.md', '/gsd-sketch-wrap-up', '/gsd:sketch --wrap-up'], - ['do.md', '/gsd-add-phase', '/gsd:phase'], - ['do.md', '/gsd-add-todo', '/gsd:capture'], - - // settings.md - ['settings.md', '/gsd-code-review-fix', '/gsd:code-review --fix'], - ['settings.md', '/gsd-settings-integrations', '/gsd:config --integrations'], - ['settings.md', '/gsd-set-profile', '/gsd:config --profile'], - ['settings.md', '/gsd-settings-advanced', '/gsd:config --advanced'], - - // discuss-phase.md - ['discuss-phase.md', '/gsd-spike-wrap-up', '/gsd:spike --wrap-up'], - ['discuss-phase.md', '/gsd-sketch-wrap-up', '/gsd:sketch --wrap-up'], - - // new-project.md - ['new-project.md', '/gsd-spike-wrap-up', '/gsd:spike --wrap-up'], - ['new-project.md', '/gsd-sketch-wrap-up', '/gsd:sketch --wrap-up'], - - // plan-phase.md - ['plan-phase.md', '/gsd-insert-phase', '/gsd:phase --insert'], - - // spike.md - ['spike.md', '/gsd-spike-wrap-up', '/gsd:spike --wrap-up'], - - // sketch.md - ['sketch.md', '/gsd-sketch-wrap-up', '/gsd:sketch --wrap-up'], -]; - -describe('bug #2950: stale deleted-command references removed from workflow files', () => { - // Build a map of file → content to avoid re-reading - const files = [...new Set(FILE_ASSERTIONS.map(([f]) => f))]; - const contentMap = {}; - for (const f of files) { - contentMap[f] = read(f); - } - - // For each (file, deletedCmd) pair, assert the old name is absent - for (const [file, deletedCmd] of FILE_ASSERTIONS) { - test(`${file}: does not contain deleted command "${deletedCmd}"`, () => { - const content = contentMap[file]; - assert.ok( - !content.includes(deletedCmd), - `${file} still contains deleted command "${deletedCmd}" — update to new form` - ); - }); - } - - // For each (file, deletedCmd, newForm) triple, assert the new form is present - for (const [file, , newForm] of FILE_ASSERTIONS) { - test(`${file}: contains new form "${newForm}"`, () => { - const content = contentMap[file]; - assert.ok( - content.includes(newForm), - `${file} is missing expected new form "${newForm}"` - ); - }); - } - - // Blanket check: no affected workflow file contains any of the deleted command names - // (catches any we might have missed in per-file assertions above) - const affectedFiles = [ - 'help.md', - 'help/modes/full.md', - 'help/modes/default.md', - 'help/modes/brief.md', - 'help/modes/topic.md', - 'do.md', - 'settings.md', - 'discuss-phase.md', - 'new-project.md', - 'plan-phase.md', - 'spike.md', - 'sketch.md', - ]; - - for (const file of affectedFiles) { - const content = read(file); - for (const deleted of DELETED_COMMANDS) { - test(`${file}: blanket check — "${deleted}" not present`, () => { - assert.ok( - !content.includes(deleted), - `${file} contains deleted command "${deleted}"` - ); - }); - } - } -}); diff --git a/tests/bug-3050-update-backup-eacces-nonfatal.test.cjs b/tests/bug-3050-update-backup-eacces-nonfatal.test.cjs deleted file mode 100644 index bff2b7004..000000000 --- a/tests/bug-3050-update-backup-eacces-nonfatal.test.cjs +++ /dev/null @@ -1,24 +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'); - -describe('bug #3050: update backup skips unreadable files non-fatally', () => { - test('update workflow backup loop wraps copyFileSync in try/catch and logs non-fatal skip', () => { - const content = fs.readFileSync( - path.join(__dirname, '..', 'gsd-core', 'workflows', 'update.md'), - 'utf8', - ); - - const hasTryCatch = /try\s*\{[\s\S]*copyFileSync\([\s\S]*\}[\s\S]*catch\s*\(err\)/.test(content); - assert.ok(hasTryCatch, 'backup copy loop must catch per-file copy errors'); - - const hasNonFatalSkipMessage = /Skipped \(non-fatal\):/.test(content); - assert.ok( - hasNonFatalSkipMessage, - 'workflow must log a non-fatal skip message for unreadable custom files', - ); - }); -}); diff --git a/tests/bug-3072-optional-sketch-findings-guard.test.cjs b/tests/bug-3072-optional-sketch-findings-guard.test.cjs deleted file mode 100644 index e8896e55e..000000000 --- a/tests/bug-3072-optional-sketch-findings-guard.test.cjs +++ /dev/null @@ -1,62 +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 ROOT = path.join(__dirname, '..', 'gsd-core', 'workflows'); - -function read(rel) { - return fs.readFileSync(path.join(ROOT, rel), 'utf8'); -} - -function extractFindingsProbesFromBashBlocks(markdown) { - const probes = []; - const fenceRe = /```bash\r?\n([\s\S]*?)```/g; - let fenceMatch; - - while ((fenceMatch = fenceRe.exec(markdown)) !== null) { - const block = fenceMatch[1]; - const baseLine = markdown.slice(0, fenceMatch.index).split(/\r?\n/).length; - const lines = block.split(/\r?\n/); - - lines.forEach((line, idx) => { - if (!line.includes('.claude/skills/')) return; - const kind = line.includes('sketch-findings-*/SKILL.md') - ? 'sketch' - : line.includes('spike-findings-*/SKILL.md') - ? 'spike' - : null; - if (!kind) return; - - probes.push({ - lineNumber: baseLine + idx, - commandText: line.trim(), - kind, - hasNonFatalGuard: /\|\|\s*true/.test(line), - }); - }); - } - - return probes; -} - -describe('bug #3072: optional sketch/spike findings probes are non-fatal', () => { - test('all sketch/spike findings SKILL.md ls probes include || true', () => { - const files = ['ui-phase.md', 'plan-phase.md', 'discuss-phase.md', 'new-project.md']; - const offenders = []; - - for (const file of files) { - const content = read(file); - const probes = extractFindingsProbesFromBashBlocks(content); - for (const probe of probes) { - if (!probe.hasNonFatalGuard) { - offenders.push(`${file}:${probe.lineNumber} ${probe.commandText}`); - } - } - } - - assert.deepStrictEqual(offenders, [], `missing non-fatal guard on optional findings probe:\n${offenders.join('\n')}`); - }); -}); diff --git a/tests/bug-3083-resume-route-clear.test.cjs b/tests/bug-3083-resume-route-clear.test.cjs deleted file mode 100644 index 4d4a3078e..000000000 --- a/tests/bug-3083-resume-route-clear.test.cjs +++ /dev/null @@ -1,28 +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'); - -describe('bug #3083: resume-project next-step routing should not include /clear then:', () => { - test('route_to_workflow block omits /clear then: in resume templates', () => { - const workflowPath = path.join(__dirname, '..', 'gsd-core', 'workflows', 'resume-project.md'); - const content = fs.readFileSync(workflowPath, 'utf-8'); - const routeStart = content.indexOf(''); - const routeEnd = content.indexOf('', routeStart); - const routeBlock = content.slice(routeStart, routeEnd); - - assert.equal(routeBlock.includes('/clear` then:'), false, 'resume route templates must not include `/clear` then:'); - }); - - test('route_to_workflow block includes exception note explaining resume behavior', () => { - const workflowPath = path.join(__dirname, '..', 'gsd-core', 'workflows', 'resume-project.md'); - const content = fs.readFileSync(workflowPath, 'utf-8'); - const routeStart = content.indexOf(''); - const routeEnd = content.indexOf('', routeStart); - const routeBlock = content.slice(routeStart, routeEnd); - - assert.match(routeBlock, /resume.*exception|exception.*resume/i); - }); -}); diff --git a/tests/bug-3096-ai-integration-phase-parallel-race.test.cjs b/tests/bug-3096-ai-integration-phase-parallel-race.test.cjs deleted file mode 100644 index a434f3c83..000000000 --- a/tests/bug-3096-ai-integration-phase-parallel-race.test.cjs +++ /dev/null @@ -1,73 +0,0 @@ -'use strict'; -// allow-test-rule: reads product workflow markdown (ai-integration-phase.md) to verify structural ordering contract — not a source-grep test - -// Regression guard for bug #3096. -// -// ai-integration-phase.md listed Steps 7+8 (gsd-ai-researcher + -// gsd-domain-researcher) without an explicit sequential ordering constraint. -// An orchestrator optimizing for speed could reasonably parallelize them -// since the sections appeared disjoint. When parallelized, gsd-domain-researcher's -// Write call at finalization replaced the whole AI-SPEC.md file with its -// in-memory copy (pre-researcher state), silently overwriting Sections 3/4. -// -// Confirmed at 40% incidence rate on a real run (2 of 5 worktree agents hit it). -// Recovery cost: one extra ai-researcher dispatch (~18 min wall). -// -// Fix: -// 1. Explicit "MUST run sequentially" note on Steps 7 and 8 -// 2. Edit-only tool discipline injected into both agent prompts - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const ROOT = path.join(__dirname, '..'); -const src = fs.readFileSync( - path.join(ROOT, 'gsd-core', 'workflows', 'ai-integration-phase.md'), - 'utf8', -); - -describe('bug #3096: ai-integration-phase sequential ordering and Edit-only discipline', () => { - test('Step 7 documents sequential ordering requirement', () => { - assert.ok( - src.includes('sequentially') || src.includes('sequential'), - 'Steps 7+8 ordering note is missing — parallel dispatch race can recur', - ); - }); - - test('Step 7 gsd-ai-researcher prompt includes Edit-only tool discipline', () => { - // The discipline block must appear before for gsd-ai-researcher - const step7Idx = src.indexOf('## 7. Spawn gsd-ai-researcher'); - const step8Idx = src.indexOf('## 8. Spawn gsd-domain-researcher'); - assert.ok(step7Idx !== -1, 'Step 7 not found'); - assert.ok(step8Idx !== -1, 'Step 8 not found'); - const step7Block = src.slice(step7Idx, step8Idx); - assert.ok( - step7Block.includes('Edit tool') && step7Block.includes('NEVER use Write'), - 'Step 7 agent prompt missing Edit-only tool discipline', - ); - }); - - test('Step 8 gsd-domain-researcher prompt includes Edit-only tool discipline', () => { - const step8Idx = src.indexOf('## 8. Spawn gsd-domain-researcher'); - const step9Idx = src.indexOf('## 9. Spawn gsd-eval-planner'); - assert.ok(step8Idx !== -1, 'Step 8 not found'); - assert.ok(step9Idx !== -1, 'Step 9 not found'); - const step8Block = src.slice(step8Idx, step9Idx); - assert.ok( - step8Block.includes('Edit tool') && step8Block.includes('NEVER use Write'), - 'Step 8 agent prompt missing Edit-only tool discipline', - ); - }); - - test('Step 8 references the wait instruction', () => { - const step8Idx = src.indexOf('## 8. Spawn gsd-domain-researcher'); - const step9Idx = src.indexOf('## 9. Spawn gsd-eval-planner'); - const step8Block = src.slice(step8Idx, step9Idx); - assert.ok( - step8Block.includes('Wait') || step8Block.includes('wait') || step8Block.includes('complete'), - 'Step 8 does not instruct orchestrator to wait for Step 7', - ); - }); -}); diff --git a/tests/bug-3120-secure-phase-empty-register.test.cjs b/tests/bug-3120-secure-phase-empty-register.test.cjs deleted file mode 100644 index 213b68b7a..000000000 --- a/tests/bug-3120-secure-phase-empty-register.test.cjs +++ /dev/null @@ -1,58 +0,0 @@ -'use strict'; -// allow-test-rule: reads product workflow markdown (secure-phase.md) to verify structural guard contract — not a source-grep test - -// Regression guard for bug #3120. -// -// secure-phase.md Step 3 short-circuited to Step 6 (write SECURITY.md) -// whenever threats_open: 0, without distinguishing between: -// Case A: All plan-time threat_model threats are CLOSED (legitimate skip) -// Case B: No threat_model blocks were written at plan time (legacy phases) -// → rubber-stamps a clean SECURITY.md with zero audit performed -// -// Fix: Step 2c tracks `register_authored_at_plan_time` (true iff ≥1 PLAN -// file contained a parseable block). Step 3 now requires BOTH -// threats_open: 0 AND register_authored_at_plan_time to skip. If only -// threats_open: 0 and NOT register_authored_at_plan_time, Step 5 runs in -// retroactive-STRIDE mode. - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const ROOT = path.join(__dirname, '..'); -const src = fs.readFileSync( - path.join(ROOT, 'gsd-core', 'workflows', 'secure-phase.md'), - 'utf8', -); - -describe('bug #3120: secure-phase short-circuit guards', () => { - test('Step 2c tracks register_authored_at_plan_time', () => { - assert.ok( - src.includes('register_authored_at_plan_time'), - 'secure-phase.md does not track register_authored_at_plan_time in Step 2c', - ); - }); - - test('Step 3 short-circuit requires both conditions', () => { - assert.ok( - src.includes('threats_open: 0 AND register_authored_at_plan_time'), - 'Step 3 short-circuit does not gate on both threats_open:0 AND register_authored_at_plan_time', - ); - }); - - test('retroactive-STRIDE mode is documented for legacy phases', () => { - assert.ok( - src.includes('retroactive') || src.includes('Retroactive'), - 'secure-phase.md does not document retroactive-STRIDE mode for legacy phases (no blocks)', - ); - }); - - test('Step 5 auditor constraint varies by mode', () => { - assert.ok( - (src.includes('Verify mitigations') || src.includes('verify mitigations')) && - (src.includes('Retroactive') || src.includes('retroactive')), - 'Step 5 does not distinguish planned vs retroactive-STRIDE auditor constraint', - ); - }); -}); diff --git a/tests/bug-3130-update-npx-robust-invocation.test.cjs b/tests/bug-3130-update-npx-robust-invocation.test.cjs deleted file mode 100644 index 6eea373db..000000000 --- a/tests/bug-3130-update-npx-robust-invocation.test.cjs +++ /dev/null @@ -1,51 +0,0 @@ -'use strict'; -// allow-test-rule: reads product workflow markdown (update.md) to verify structural invocation contract — not a source-grep test - -// Regression guard for bug #3130. -// -// Two failure modes were observed with the pre-fix npx invocation form: -// 1. Cache-stale: bare `npx -y @opengsd/gsd-core@` hits npx's local -// cache and may pull an older version instead of the target tag. -// 2. Token-routing: Bash-tool wrappers misroute the `@` token in -// `@opengsd/gsd-core@`, causing npm to error with -// "Unknown command: @opengsd/gsd-core@". -// -// The robust form is: -// npx -y --package=@opengsd/gsd-core@"$TAG" -- gsd-core $ARGS -// -// `--package=` forces a fresh registry fetch, bypassing the npx cache. -// `--` clearly delineates npx flags from the run-command, preventing -// Bash-tool @-token misrouting. -// `$TAG` is a shell variable (latest by default, next under --next/--rc), -// set by the parse_update_channel step (#815). - -const { test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const ROOT = path.join(__dirname, '..'); -const UPDATE_WF = path.join(ROOT, 'gsd-core', 'workflows', 'update.md'); - -const src = fs.readFileSync(UPDATE_WF, 'utf8'); - -test('bug #3130: update.md contains no bare npx invocations (cache-stale form)', () => { - // Any occurrence of `npx -y @opengsd/gsd-core@` without `--package=` - // is the stale form that triggers the two failure modes. - const stale = (src.match(/npx -y @opengsd\/gsd-core@\S+[^\r\n]*/g) || []); - assert.deepEqual( - stale, - [], - `Stale npx forms found in update.md (must use --package= form): ${stale.join('; ')}`, - ); -}); - -test('bug #3130: update.md has >=3 robust npx invocations (--package= + -- separator)', () => { - // Three sibling invocations: local, global, and unknown/fallback. - // The tag is now a $TAG variable (latest by default, next under --next/--rc). - const robust = (src.match(/npx -y --package=@opengsd\/gsd-core@\S+ -- gsd-core/g) || []); - assert.ok( - robust.length >= 3, - `Expected >=3 robust npx invocations in update.md, found ${robust.length}`, - ); -}); diff --git a/tests/bug-3195-quick-resurrection-guard.test.cjs b/tests/bug-3195-quick-resurrection-guard.test.cjs deleted file mode 100644 index 7dfc1e6e2..000000000 --- a/tests/bug-3195-quick-resurrection-guard.test.cjs +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Drift-guard for bug #3195: quick.md and execute-phase.md must both use - * the same resurrection-detection approach so they stay in sync. - * - * After #3797: both workflows delegate worktree cleanup to the SDK's - * worktree.cleanup-wave command, which implements resurrection detection - * (diff --diff-filter=D history checks) internally. The inline WAS_DELETED - * shell variable form has been removed from both workflows — it was part of - * the SDK-absence fallback which is now dead code since preflight exits if - * neither local nor global SDK is available. - * - * This test ensures both workflows continue to use the same cleanup - * mechanism (SDK delegation), not one inline and one delegated. - */ - -'use strict'; - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const QUICK_MD = path.join( - __dirname, '..', 'gsd-core', 'workflows', 'quick.md' -); -const EXECUTE_PHASE_MD = path.join( - __dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md' -); - -describe('resurrection guard drift check — quick.md vs execute-phase.md (#3195)', () => { - let quickContent; - let executePhaseContent; - - test('both workflow files are readable', () => { - quickContent = fs.readFileSync(QUICK_MD, 'utf-8'); - executePhaseContent = fs.readFileSync(EXECUTE_PHASE_MD, 'utf-8'); - assert.ok(quickContent.length > 0, 'quick.md must not be empty'); - assert.ok(executePhaseContent.length > 0, 'execute-phase.md must not be empty'); - }); - - test('quick.md delegates resurrection detection to SDK (worktree.cleanup-wave)', () => { - if (!quickContent) quickContent = fs.readFileSync(QUICK_MD, 'utf-8'); - // After #3797: quick.md delegates to worktree.cleanup-wave, which handles - // resurrection detection (diff --diff-filter=D) internally. The inline - // WAS_DELETED form has been removed — it was part of the SDK-absence fallback. - assert.ok( - quickContent.includes('worktree.cleanup-wave'), - 'quick.md must delegate to worktree.cleanup-wave for resurrection detection (#3195/#3797)' - ); - }); - - test('execute-phase.md delegates resurrection detection to SDK (worktree.cleanup-wave)', () => { - if (!executePhaseContent) executePhaseContent = fs.readFileSync(EXECUTE_PHASE_MD, 'utf-8'); - // After #3797: execute-phase.md delegates to worktree.cleanup-wave, which handles - // resurrection detection (diff --diff-filter=D) internally. - assert.ok( - executePhaseContent.includes('worktree.cleanup-wave'), - 'execute-phase.md must delegate to worktree.cleanup-wave for resurrection detection (#3195/#3797)' - ); - }); - - test('both workflows use the same cleanup mechanism (SDK delegation parity)', () => { - if (!quickContent) quickContent = fs.readFileSync(QUICK_MD, 'utf-8'); - if (!executePhaseContent) executePhaseContent = fs.readFileSync(EXECUTE_PHASE_MD, 'utf-8'); - const quickDelegates = quickContent.includes('worktree.cleanup-wave'); - const executeDelegates = executePhaseContent.includes('worktree.cleanup-wave'); - assert.strictEqual( - quickDelegates, - executeDelegates, - 'quick.md and execute-phase.md must both use the same cleanup mechanism (SDK delegation parity, #3195)' - ); - }); - - test('quick.md does not use the buggy PRE_MERGE_FILES grep form', () => { - if (!quickContent) quickContent = fs.readFileSync(QUICK_MD, 'utf-8'); - // The buggy pattern: deletion conditioned on absence from PRE_MERGE_FILES snapshot - const hasBuggyGuard = - quickContent.includes('PRE_MERGE_FILES') && - /if\s*!\s*echo\s*"\$PRE_MERGE_FILES"\s*\|\s*grep\s+-qxF\s*"\$RESURRECTED"/.test(quickContent); - assert.ok( - !hasBuggyGuard, - 'quick.md must NOT delete files based on the PRE_MERGE_FILES snapshot grep (inverted guard bug #3195)' - ); - }); - - test('execute-phase.md does not use the buggy PRE_MERGE_FILES grep form', () => { - if (!executePhaseContent) executePhaseContent = fs.readFileSync(EXECUTE_PHASE_MD, 'utf-8'); - const hasBuggyGuard = - executePhaseContent.includes('PRE_MERGE_FILES') && - /if\s*!\s*echo\s*"\$PRE_MERGE_FILES"\s*\|\s*grep\s+-qxF\s*"\$RESURRECTED"/.test(executePhaseContent); - assert.ok( - !hasBuggyGuard, - 'execute-phase.md must NOT delete files based on the PRE_MERGE_FILES snapshot grep (inverted guard bug)' - ); - }); -}); diff --git a/tests/bug-3236-capture-seed-one-shot.test.cjs b/tests/bug-3236-capture-seed-one-shot.test.cjs deleted file mode 100644 index a4406e36a..000000000 --- a/tests/bug-3236-capture-seed-one-shot.test.cjs +++ /dev/null @@ -1,165 +0,0 @@ -// allow-test-rule: source-text-is-the-product — workflow and command .md files -// ARE what the runtime loads; asserting their existence and behavioral content -// tests the deployed skill surface contract, not implementation internals. - -'use strict'; - -// Regression tests for bug #3236. -// -// The `plant-seed.md` workflow gained a mandatory Trigger / Why / Scope -// questionnaire (gather_context step) that blocks before the seed file is -// written. Users capturing a stream of ideas lose flow because the AI must -// receive three answers before a single write happens. -// -// Fix: the seed file must be written FIRST (one-shot), with sensible defaults -// for Trigger / Why / Scope. The enrichment questions must be optional and must -// come AFTER the file is written, not before. -// -// Behavioral contract tested here: -// 1. The `write-seed` step exists and comes BEFORE any AskUserQuestion for -// Trigger / Why / Scope enrichment. -// 2. The workflow provides sensible defaults for trigger_when and scope when -// the user supplies only the idea summary. -// 3. The AskUserQuestion calls for Trigger / Why / Scope still exist (optional -// enrichment path preserved) but are gated after the file is written. - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const ROOT = path.join(__dirname, '..'); -const PLANT_SEED = path.join(ROOT, 'gsd-core', 'workflows', 'plant-seed.md'); - -// ── helpers ─────────────────────────────────────────────────────────────────── - -function readPlantSeed() { - try { - return fs.readFileSync(PLANT_SEED, 'utf8'); - } catch (err) { - throw new Error('gsd-core/workflows/plant-seed.md not found: ' + err.message); - } -} - -/** - * Extract step names in document order from workflow XML. - */ -function extractStepNames(src) { - const names = []; - const re = / { - test('plant-seed.md exists', () => { - assert.ok( - fs.existsSync(PLANT_SEED), - 'gsd-core/workflows/plant-seed.md does not exist', - ); - }); - - test('write-seed step exists (hyphenated name per CONTEXT.md rule)', () => { - const src = readPlantSeed(); - const steps = extractStepNames(src); - assert.ok( - steps.includes('write-seed'), - 'plant-seed.md must contain a (hyphens, not underscores); found: ' + JSON.stringify(steps), - ); - }); - - test('write-seed step appears before Trigger AskUserQuestion', () => { - const src = readPlantSeed(); - const writeOff = stepOffset(src, 'write-seed'); - assert.ok(writeOff !== -1, 'write-seed step must exist'); - const triggerOff = askQuestionOffset(src, 'Trigger'); - if (triggerOff === -1) return; // fully optional — no question at all is fine - assert.ok( - writeOff < triggerOff, - 'write-seed (offset ' + writeOff + ') must precede Trigger question (offset ' + triggerOff + ')', - ); - }); - - test('write-seed step appears before Why AskUserQuestion', () => { - const src = readPlantSeed(); - const writeOff = stepOffset(src, 'write-seed'); - assert.ok(writeOff !== -1, 'write-seed step must exist'); - const whyOff = askQuestionOffset(src, 'Why'); - if (whyOff === -1) return; - assert.ok( - writeOff < whyOff, - 'write-seed (offset ' + writeOff + ') must precede Why question (offset ' + whyOff + ')', - ); - }); - - test('write-seed step appears before Scope AskUserQuestion', () => { - const src = readPlantSeed(); - const writeOff = stepOffset(src, 'write-seed'); - assert.ok(writeOff !== -1, 'write-seed step must exist'); - const scopeOff = askQuestionOffset(src, 'Scope'); - if (scopeOff === -1) return; - assert.ok( - writeOff < scopeOff, - 'write-seed (offset ' + writeOff + ') must precede Scope question (offset ' + scopeOff + ')', - ); - }); - - test('workflow documents a default value for trigger_when', () => { - const src = readPlantSeed(); - assert.ok( - /trigger_when.*default|default.*trigger|when relevant|when scope matches|unspecified/i.test(src), - 'plant-seed.md must document a default for trigger_when when the user provides no Trigger', - ); - }); - - test('workflow documents a default value for scope', () => { - const src = readPlantSeed(); - assert.ok( - /scope.*default|default.*scope|scope.*unknown|unknown|scope.*unspecified|unspecified/i.test(src), - 'plant-seed.md must document a default for scope when the user provides no Scope', - ); - }); - - test('write-seed step precedes enrich/gather step in step order', () => { - const src = readPlantSeed(); - const steps = extractStepNames(src); - const writeIdx = steps.findIndex((s) => s === 'write-seed' || s === 'write_seed'); - const gatherIdx = steps.findIndex( - (s) => s === 'gather-context' || s === 'gather_context' || s === 'enrich-seed' || s === 'enrich_seed', - ); - if (gatherIdx === -1 || writeIdx === -1) return; // no gather step — one-shot only path, fine - assert.ok( - writeIdx < gatherIdx, - 'write-seed (step index ' + writeIdx + ') must appear before gather/enrich (index ' + gatherIdx + ') — capture first, enrich later', - ); - }); - - test('optional enrichment path is preserved', () => { - const src = readPlantSeed(); - const hasEnrichStep = /enrich|gather.context|gather_context/i.test(src); - const hasTriggerQuestion = src.includes('header: "Trigger"'); - assert.ok( - hasEnrichStep || hasTriggerQuestion, - 'plant-seed.md must preserve an optional enrichment path (enrich step or Trigger/Why/Scope AskUserQuestion)', - ); - }); -}); diff --git a/tests/bug-3320-planner-deep-work-rules.test.cjs b/tests/bug-3320-planner-deep-work-rules.test.cjs deleted file mode 100644 index 8bf31a959..000000000 --- a/tests/bug-3320-planner-deep-work-rules.test.cjs +++ /dev/null @@ -1,92 +0,0 @@ -'use strict'; - -// allow-test-rule: source-text-is-product [#3320] -// The bug is a contradiction in prompt/workflow source text. These assertions -// intentionally pin the contract words that planner agents consume. - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const ROOT = path.join(__dirname, '..'); -const PLANNER_AGENT = path.join(ROOT, 'agents', 'gsd-planner.md'); -const PLAN_PHASE_WORKFLOW = path.join(ROOT, 'gsd-core', 'workflows', 'plan-phase.md'); - -function read(relativePath) { - return fs.readFileSync(path.join(ROOT, relativePath), 'utf8'); -} - -function extractDeepWorkRules() { - const workflow = fs.readFileSync(PLAN_PHASE_WORKFLOW, 'utf8'); - const match = workflow.match(/[\s\S]*?<\/deep_work_rules>/); - assert.ok(match, 'plan-phase.md must contain a deep_work_rules block'); - return match[0]; -} - -describe('bug #3320 planner action contract', () => { - test('planner agent explicitly keeps implementation code out of action blocks', () => { - const planner = fs.readFileSync(PLANNER_AGENT, 'utf8'); - - assert.match( - planner, - /NEVER place fenced code blocks \(```\) inside ``/, - 'gsd-planner.md must explicitly forbid fenced implementation code in ' - ); - assert.match( - planner, - /Code excerpts belong in `` source files or referenced context/, - 'gsd-planner.md must route code excerpts to context/read-first material' - ); - }); - - test('plan-phase deep_work_rules no longer requires self-sufficient code dumps', () => { - const deepWorkRules = extractDeepWorkRules(); - - assert.doesNotMatch( - deepWorkRules, - /copy them into the action verbatim/, - 'deep_work_rules must not tell planners to copy source material verbatim into ' - ); - assert.doesNotMatch( - deepWorkRules, - /complete the task from the action text alone/, - 'deep_work_rules must not make self-sufficient without read_first/context' - ); - assert.match( - deepWorkRules, - /Do not include full file contents, fenced code blocks, or complete implementations in ``/, - 'deep_work_rules must explicitly bound concrete values to avoid code dumping' - ); - }); - - test('plan-phase acceptance criteria allow behavior and test assertions', () => { - const deepWorkRules = extractDeepWorkRules(); - - assert.match( - deepWorkRules, - /behavior assertion/, - 'acceptance criteria must allow behavior assertions, not just grep checks' - ); - assert.match( - deepWorkRules, - /test command/, - 'acceptance criteria must allow test-command assertions' - ); - }); - - test('quality gate matches the reconciled planner contract', () => { - const workflow = read('gsd-core/workflows/plan-phase.md'); - - assert.match( - workflow, - /Every task has `` with behavior, test-command, CLI, or source assertions/, - 'quality gate must not narrow acceptance criteria back to grep-only checks' - ); - assert.match( - workflow, - /Every `` contains concrete identifiers without fenced code blocks or full implementations/, - 'quality gate must enforce concrete prose without implementation dumps' - ); - }); -}); diff --git a/tests/bug-3381-verify-work-workstream.test.cjs b/tests/bug-3381-verify-work-workstream.test.cjs deleted file mode 100644 index 6223793b3..000000000 --- a/tests/bug-3381-verify-work-workstream.test.cjs +++ /dev/null @@ -1,80 +0,0 @@ -// allow-test-rule: source-text-is-the-product — verify-work.md is a runtime workflow contract. - -'use strict'; - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -describe('bug #1716: resume_from_file routes to complete_session when no [pending] tests remain', () => { - test('resume_from_file step contains guard clause for zero-pending (all-blocked) case', () => { - const workflow = fs.readFileSync( - path.join(__dirname, '..', 'gsd-core', 'workflows', 'verify-work.md'), - 'utf8', - ); - - const stepStart = workflow.indexOf(''); - assert.ok(stepStart !== -1, 'resume_from_file step must exist'); - - const stepEnd = workflow.indexOf('', stepStart); - const stepBody = workflow.slice(stepStart, stepEnd); - - // Guard must appear immediately after the find-pending instruction. - // Without it, all-blocked sessions (pending_count==0, blocked_count>0) - // silently terminate and never reach complete_session (#1716). - const findIdx = stepBody.indexOf("Find first test with `result: [pending]`."); - const guardIdx = stepBody.indexOf("If no `[pending]` test found → go to `complete_session`."); - - assert.ok(findIdx !== -1, 'find-pending instruction must be present'); - assert.ok(guardIdx !== -1, 'guard clause for zero-pending case must be present'); - assert.ok(guardIdx > findIdx, 'guard must appear after find-pending instruction'); - - const between = stepBody - .slice(findIdx + "Find first test with `result: [pending]`.".length, guardIdx) - .trim(); - assert.strictEqual(between, '', 'guard must be the next non-whitespace line after find-pending'); - }); -}); - -describe('bug #3381: verify-work forwards workstream context', () => { - test('workflow forwards ${GSD_WS} to workstream-sensitive SDK queries', () => { - const workflow = fs.readFileSync( - path.join(__dirname, '..', 'gsd-core', 'workflows', 'verify-work.md'), - 'utf8', - ); - - assert.match(workflow, /GSD_WS=""/, 'verify-work must initialize GSD_WS'); - assert.match( - workflow, - /grep -qE -- '--ws\[\[:space:\]\]\+\[\^\[:space:\]\]\+'/, - 'verify-work must detect --ws in $ARGUMENTS', - ); - assert.match( - workflow, - /grep -oE -- '--ws\[\[:space:\]\]\+\[\^\[:space:\]\]\+'/, - 'verify-work must extract the --ws flag pair from $ARGUMENTS', - ); - assert.match( - workflow, - /PHASE_ARG=\$\(echo "\$ARGUMENTS" \| sed -E 's\/--ws\[\[:space:\]\]\+\[\^\[:space:\]\]\+\/\/g' \| xargs\)/, - 'verify-work must derive PHASE_ARG after removing --ws', - ); - // After #3797 architectural fix, callsites use gsd_run - assert.match( - workflow, - /gsd_run query init\.verify-work "\$\{PHASE_ARG\}" \$\{GSD_WS\}/, - 'init.verify-work must receive GSD_WS so phase_dir resolves in workstreams', - ); - assert.match( - workflow, - /gsd_run query phase\.mvp-mode "\$\{phase_number\}" \$\{GSD_WS\} --pick active/, - 'phase.mvp-mode must receive GSD_WS so roadmap mode is workstream-scoped', - ); - assert.match( - workflow, - /gsd_run query roadmap\.get-phase "\$\{phase_number\}" \$\{GSD_WS\} --pick goal/, - 'roadmap.get-phase must receive GSD_WS so goals are workstream-scoped', - ); - }); -}); diff --git a/tests/bug-3516-reapply-patches-gsd-update-filter.test.cjs b/tests/bug-3516-reapply-patches-gsd-update-filter.test.cjs deleted file mode 100644 index 8260835cd..000000000 --- a/tests/bug-3516-reapply-patches-gsd-update-filter.test.cjs +++ /dev/null @@ -1,123 +0,0 @@ -// allow-test-rule: source-text-is-the-product -// gsd-core/workflows/reapply-patches.md is the installed runtime workflow — -// its text IS the deployed behavioral contract for the --reapply flag. - -'use strict'; - -/** - * Bug #3516: reapply-patches.md git-enhanced two-way merge filter misses - * commits authored by the renamed `/gsd-update` flow. - * - * The `grep -v` alternation on line 231 only included the legacy `gsd:update` - * marker. After the slash-command rename `/gsd:update` → `/gsd-update`, commits - * authored by the current flow fall through the filter and are misclassified as - * user customizations, prompting spurious merge conflicts during `--reapply`. - * - * Fix: add `gsd-update` arm to the alternation so both the legacy and current - * commit-message prefixes are excluded. `GSD update` and `gsd-install` - * exclusions are preserved. - * - * Per the repo's source-text-is-the-product exception: the workflow file's text - * IS the deployed behavioral contract. Structural assertion against the parsed - * shell command string is the correct test form here. - */ - -const { describe, test, before } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const WORKFLOW_PATH = path.join( - __dirname, - '..', - 'gsd-core', - 'workflows', - 'reapply-patches.md', -); - -/** - * Extract the git log filter command from the workflow. - * - * Looks for the `grep -v "..."` shell snippet inside the Git-enhanced two-way - * merge section and returns the alternation string between the quotes. - * Returns null if the snippet is absent (signals a structural regression). - */ -function extractFilterAlternation(content) { - // Match the grep -v "..." line in the bash block - const match = content.match(/grep\s+-v\s+"([^"]+)"/); - if (!match) return null; - return match[1]; -} - -/** - * Parse the alternation string (pipe-delimited) into individual arms. - * Handles escaped pipes produced by shell regex syntax (`\|`). - */ -function parseAlternationArms(alternation) { - // Shell grep alternation uses \| (escaped pipe); split on that - return alternation.split(/\\\|/).map((arm) => arm.trim()); -} - -describe('Bug #3516: git-enhanced two-way merge filter includes gsd-update arm', () => { - let content; - let alternation; - let arms; - - before(() => { - content = fs.readFileSync(WORKFLOW_PATH, 'utf8'); - alternation = extractFilterAlternation(content); - arms = alternation ? parseAlternationArms(alternation) : []; - }); - - test('workflow file exists', () => { - assert.ok( - fs.existsSync(WORKFLOW_PATH), - 'gsd-core/workflows/reapply-patches.md must exist', - ); - }); - - test('git-enhanced two-way merge section contains a grep -v filter', () => { - assert.ok( - alternation !== null, - 'reapply-patches.md must contain a `grep -v "..."` filter in the git-enhanced two-way merge section', - ); - }); - - test('filter excludes legacy gsd:update commits (back-compat)', () => { - assert.ok( - arms.some((arm) => arm === 'gsd:update'), - `filter must include 'gsd:update' arm for back-compat; got arms: ${JSON.stringify(arms)}`, - ); - }); - - test('filter excludes renamed gsd-update commits (primary fix)', () => { - assert.ok( - arms.some((arm) => arm === 'gsd-update'), - `filter must include 'gsd-update' arm (renamed flow); got arms: ${JSON.stringify(arms)}`, - ); - }); - - test('filter excludes GSD update commits (no regression)', () => { - assert.ok( - arms.some((arm) => arm === 'GSD update'), - `filter must include 'GSD update' arm; got arms: ${JSON.stringify(arms)}`, - ); - }); - - test('filter excludes gsd-install commits (no regression)', () => { - assert.ok( - arms.some((arm) => arm === 'gsd-install'), - `filter must include 'gsd-install' arm; got arms: ${JSON.stringify(arms)}`, - ); - }); - - test('all four expected exclusion patterns are present in the filter', () => { - const required = ['gsd:update', 'gsd-update', 'GSD update', 'gsd-install']; - const missing = required.filter((p) => !arms.some((arm) => arm === p)); - assert.deepEqual( - missing, - [], - `filter is missing required exclusion patterns: ${JSON.stringify(missing)}; got arms: ${JSON.stringify(arms)}`, - ); - }); -}); diff --git a/tests/bug-3521-quick-cleanup-cwd-pin.test.cjs b/tests/bug-3521-quick-cleanup-cwd-pin.test.cjs deleted file mode 100644 index f852bd760..000000000 --- a/tests/bug-3521-quick-cleanup-cwd-pin.test.cjs +++ /dev/null @@ -1,70 +0,0 @@ -// allow-test-rule: source-text-is-the-product -// quick.md is the shipped orchestration contract for /gsd-quick; this -// regression test previously locked the CWD-safety guard in the manual shell -// cleanup loop. After #3797, quick.md delegates cleanup entirely to the SDK's -// worktree.cleanup-wave command, which encapsulates CWD-pinning, STATE.md/ -// ROADMAP.md backup/restore, and deletion guards internally. -// -// This test file now verifies the delegation contract: quick.md calls -// worktree.cleanup-wave with || exit 1 (fail-closed), which enforces the -// safety semantics that were previously implemented inline in the shell loop. - -'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 QUICK_MD = path.join(__dirname, '..', 'gsd-core', 'workflows', 'quick.md'); - -function readQuickMd() { - return fs.readFileSync(QUICK_MD, 'utf8'); -} - -describe('bug #3521 — quick.md post-merge cleanup CWD safety (via SDK delegation, #3797)', () => { - - test('quick.md is readable', () => { - const content = readQuickMd(); - assert.ok(content.length > 0, 'quick.md must not be empty'); - }); - - test('quick.md cleanup delegates CWD-safe worktree cleanup to SDK (worktree.cleanup-wave)', () => { - const content = readQuickMd(); - // After #3797: quick.md delegates to gsd_run query worktree.cleanup-wave - // which handles CWD pinning, STATE.md backup, deletion guards, and branch - // cleanup internally. The manual shell loop has been removed. - assert.ok( - content.includes('worktree.cleanup-wave'), - 'quick.md must delegate cleanup to gsd_run query worktree.cleanup-wave (#3797)', - ); - }); - - test('quick.md cleanup-wave call uses || exit 1 to enforce fail-closed safety (#3521 contract)', () => { - const content = readQuickMd(); - // The || exit 1 enforces fail-closed: SDK safety refusals (e.g. branch - // drift detection from #3174) surface immediately rather than being swallowed. - // This is the equivalent of the pre-#3797 `gsd_run query ... || exit 1` in the - // `if command -v gsd-sdk` branch. - assert.match( - content, - /gsd_run query worktree\.cleanup-wave.*\|\| exit 1/, - 'quick.md cleanup-wave must use || exit 1 — fail-closed for safety refusals (#3521/#3797)', - ); - }); - - test('quick.md manifest guard still blocks broad cleanup when manifest is missing (#3384)', () => { - const content = readQuickMd(); - // The manifest guard must still be present before the cleanup-wave call - // to prevent broad worktree cleanup when the manifest file is absent. - assert.ok( - content.includes('QUICK_WORKTREE_MANIFEST') || content.includes('WAVE_WORKTREE_MANIFEST'), - 'quick.md must still guard cleanup behind QUICK_WORKTREE_MANIFEST (#3384)', - ); - assert.ok( - content.includes('refusing broad worktree cleanup') || content.includes('missing QUICK_WORKTREE_MANIFEST'), - 'quick.md must emit a blocked message when the manifest is missing (#3384)', - ); - }); - -}); diff --git a/tests/bug-3784-gsd-settings-model-profile-ui-omits-adaptive.test.cjs b/tests/bug-3784-gsd-settings-model-profile-ui-omits-adaptive.test.cjs deleted file mode 100644 index 36ddfb7c6..000000000 --- a/tests/bug-3784-gsd-settings-model-profile-ui-omits-adaptive.test.cjs +++ /dev/null @@ -1,197 +0,0 @@ -'use strict'; - -// allow-test-rule: source-text-is-the-product -// The deployed settings.md IS the product — testing its text content tests the deployed contract. - -/** - * Regression test for bug #3784 - * - * /gsd-settings model profile UI omits `adaptive`. The AskUserQuestion block - * for model_profile in settings.md lists only four options (Quality, Balanced, - * Budget, Inherit) but the settings schema registers five valid profiles: - * quality, balanced, budget, adaptive, inherit. The `adaptive` profile is - * reachable by name via `gsd:config --profile adaptive` but cannot be selected - * interactively through `/gsd:settings`. - * - * Root cause: the options array in the model-profile AskUserQuestion block was - * written before the `adaptive` profile was introduced and was never updated. - * Because AskUserQuestion enforces a hard 4-option cap, the fix uses a two- - * question split: Q1 asks "Standard tier or Adaptive?" (2 options); if the - * user picks Standard, Q2 asks which of the three standard profiles to use - * (Quality, Balanced, Budget). This keeps every call within the 4-option cap - * while making all five profiles reachable. - * - * Fixes: #3784 - */ - -const { describe, test, before } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const SETTINGS_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'settings.md'); - -/** - * Extract all AskUserQuestion option labels from a text block. - * Returns them lowercased for case-insensitive comparison. - */ -function extractOptionLabels(block) { - const labelPattern = /label:\s*"([^"]+)"/g; - const labels = []; - let match; - while ((match = labelPattern.exec(block)) !== null) { - labels.push(match[1].toLowerCase()); - } - return labels; -} - -describe('bug #3784: settings.md model profile UI exposes all 5 profiles', () => { - let content; - let presentBlock; - - before(() => { - content = fs.readFileSync(SETTINGS_PATH, 'utf-8'); - const presentMatch = content.match(/[\s\S]*?<\/step>/); - assert.ok(presentMatch, 'settings.md must have a present_settings step'); - presentBlock = presentMatch[0]; - }); - - // ── Core contract: all five valid profiles reachable via the settings UI ── - - test('present_settings step includes Adaptive as a selectable option (#3784)', () => { - // This is the primary assertion for bug #3784 — adaptive was missing. - const labels = extractOptionLabels(presentBlock); - assert.ok( - labels.some(l => l === 'adaptive' || l.startsWith('adaptive')), - [ - 'Bug #3784: present_settings step must include an "Adaptive" label in', - 'its model profile AskUserQuestion options so users can select it', - `interactively. Got labels: [${labels.join(', ')}]`, - ].join(' ') - ); - }); - - test('present_settings step includes Quality as a selectable option', () => { - const labels = extractOptionLabels(presentBlock); - assert.ok( - labels.some(l => l === 'quality' || l.startsWith('quality')), - `present_settings step must include a "Quality" option. Got: [${labels.join(', ')}]` - ); - }); - - test('present_settings step includes Balanced as a selectable option', () => { - const labels = extractOptionLabels(presentBlock); - assert.ok( - labels.some(l => l === 'balanced' || l.startsWith('balanced')), - `present_settings step must include a "Balanced" option. Got: [${labels.join(', ')}]` - ); - }); - - test('present_settings step includes Budget as a selectable option', () => { - const labels = extractOptionLabels(presentBlock); - assert.ok( - labels.some(l => l === 'budget' || l.startsWith('budget')), - `present_settings step must include a "Budget" option. Got: [${labels.join(', ')}]` - ); - }); - - test('present_settings step includes Inherit as a selectable option', () => { - const labels = extractOptionLabels(presentBlock); - assert.ok( - labels.some(l => l === 'inherit' || l.startsWith('inherit')), - `present_settings step must include an "Inherit" option. Got: [${labels.join(', ')}]` - ); - }); - - // ── update_config step writes adaptive as a valid value ── - - test('update_config step lists adaptive as a valid model_profile value', () => { - const updateMatch = content.match(/[\s\S]*?<\/step>/); - assert.ok(updateMatch, 'settings.md must have an update_config step'); - const block = updateMatch[0]; - assert.ok( - block.includes('adaptive'), - 'update_config step must list "adaptive" as a valid model_profile value' - ); - }); - - // ── confirm step displays adaptive as a possible profile value ── - - test('confirm step table shows adaptive as a possible model profile value', () => { - const confirmMatch = content.match(/[\s\S]*?<\/step>/); - assert.ok(confirmMatch, 'settings.md must have a confirm step'); - const block = confirmMatch[0]; - assert.ok( - block.includes('adaptive'), - 'confirm step must include "adaptive" in the Model Profile row placeholder' - ); - }); - - // ── adaptive described with role-based routing semantics ── - - test('settings.md describes adaptive profile with role-based routing semantics', () => { - // Adaptive uses heavy/light role tiers per routingTier. - // The UI description must convey role-based cost optimization and the heavy/light tier - // split — not just mention "Adaptive" somewhere (that word appears 6+ times in the file). - const lower = content.toLowerCase(); - assert.ok( - lower.includes('role-based cost optimization') && lower.includes('heavy roles'), - 'settings.md must describe the adaptive profile with "role-based cost optimization" and "heavy roles" wording so the description is meaningful across all supported runtimes' - ); - }); - - // ── 4-option cap enforcement ── - - test('each question object in present_settings AskUserQuestion blocks has at most 4 options (AskUserQuestion runtime cap)', () => { - // The AskUserQuestion runtime enforces a hard 4-option cap per individual question object - // (each { question:..., options:[...] } entry). This test guards against a naïve revert - // that puts all 5 profiles into a single question object instead of using the Q1/Q2 split. - const ASK_USER_QUESTION_OPTION_CAP = 4; // hard limit enforced by the AskUserQuestion runtime - - // Extract each individual options array by finding 'options: [' and walking to the - // matching balanced ']', then count label: entries within that span. - const optionsKeyRe = /\boptions\s*:\s*\[/g; - let match; - let questionIndex = 0; - while ((match = optionsKeyRe.exec(presentBlock)) !== null) { - questionIndex++; - // Walk forward from the opening '[' to find the balanced close ']'. - let depth = 0; - const start = match.index + match[0].length - 1; // points at '[' - let end = start; - for (let k = start; k < presentBlock.length; k++) { - if (presentBlock[k] === '[') { depth++; } - else if (presentBlock[k] === ']') { - depth--; - if (depth === 0) { end = k; break; } - } - } - const optionsBody = presentBlock.slice(start, end + 1); - const labelMatches = optionsBody.match(/label:\s*"[^"]+"/g) || []; - const optionCount = labelMatches.length; - assert.ok( - optionCount <= ASK_USER_QUESTION_OPTION_CAP, - `Question object ${questionIndex} in present_settings has ${optionCount} options — exceeds the runtime cap of ${ASK_USER_QUESTION_OPTION_CAP}. Split into multiple questions (as #3784 did for model_profile).` - ); - } - // Sanity check: there must be at least one options array found. - assert.ok(questionIndex > 0, 'present_settings must contain at least one AskUserQuestion options array'); - }); - - // ── Brace-balance regression (bd53925f fixed duplicate '{' from 35fc1d21) ── - - test('present_settings step has balanced braces — regression: brace-balance after #3784 split', () => { - // commit bd53925f fixed a duplicate '{' introduced by 35fc1d21 when the model-profile - // AskUserQuestion was split into Q1+Q2. This test guards against a recurrence. - let depth = 0; - for (const ch of presentBlock) { - if (ch === '{') { depth++; } - if (ch === '}') { depth--; } - } - assert.strictEqual( - depth, - 0, - `present_settings step has unbalanced braces: net depth after full scan is ${depth} (positive = extra '{', negative = extra '}'). Regression guard for bd53925f / #3784.` - ); - }); -}); diff --git a/tests/bug-3805-fast-md-log-to-state-schema.test.cjs b/tests/bug-3805-fast-md-log-to-state-schema.test.cjs deleted file mode 100644 index b3756462a..000000000 --- a/tests/bug-3805-fast-md-log-to-state-schema.test.cjs +++ /dev/null @@ -1,143 +0,0 @@ -'use strict'; - -// allow-test-rule: source-text-is-the-product -// Reads gsd-core/workflows/fast.md whose deployed text IS the product — -// the workflow markdown is executed verbatim by LLM runtimes. - -/** - * #3805 — fast.md log_to_state appends a schema-blind 4-column row to the - * 5-column "Quick Tasks Completed" table created by quick.md Step 7. - * - * quick.md Step 7 creates the table with 5 columns: - * | # | Description | Date | Commit | Directory | - * - * Before this fix, fast.md's log_to_state step appended a hardcoded 4-cell - * row unconditionally: - * echo "| $(date +%Y-%m-%d) | fast | $TASK | ✅ |" >> .planning/STATE.md - * - * This produces malformed Markdown when the existing table has a different - * column count. - * - * Covers: - * - fast.md does NOT contain the hardcoded 4-cell echo template - * - fast.md log_to_state step reads/introspects the existing table header - * before appending (schema-aware insertion) - * - fast.md log_to_state step matches the 5-column schema from quick.md - * Step 7 when that table is present - * - fast.md log_to_state step skips (does not corrupt) the STATE.md write - * when the table schema is unrecognized - */ - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const REPO_ROOT = path.join(__dirname, '..'); -const FAST_MD_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'fast.md'); - -// The 5-column schema defined in quick.md Step 7 (non-validate mode). -// Column count is 5: # | Description | Date | Commit | Directory -// Named constant for traceability — mirrors quick.md Step 7's table header. -const QUICK_MD_STEP7_COL_COUNT = 5; -const QUICK_MD_STEP7_COLUMNS = ['#', 'Description', 'Date', 'Commit', 'Directory']; - -describe('bug #3805: fast.md log_to_state must be schema-aware', () => { - let fastMdContent; - - test('fast.md workflow file exists and is readable', () => { - assert.ok(fs.existsSync(FAST_MD_PATH), `fast.md not found at ${FAST_MD_PATH}`); - fastMdContent = fs.readFileSync(FAST_MD_PATH, 'utf-8'); - }); - - test('fast.md log_to_state step does NOT hardcode a 4-cell row template', () => { - // The old broken template: | date | fast | task | ✅ | - // This regex matches the exact hardcoded pattern that ignores table schema. - // A 4-cell row has exactly 4 pipe-delimited fields plus the surrounding pipes. - const hardcoded4CellPattern = /echo\s+["'][|][^|]*[|][^|]*[|][^|]*[|][^|]*[|]\s*["']/; - const match = fastMdContent.match(hardcoded4CellPattern); - assert.ok( - !match, - [ - 'fast.md log_to_state still contains the hardcoded 4-cell row template:', - ` "${match?.[0]}"`, - 'This appends a malformed row to the 5-column Quick Tasks Completed table', - 'created by quick.md Step 7.', - `Expected table schema (${QUICK_MD_STEP7_COL_COUNT} cols): | ${QUICK_MD_STEP7_COLUMNS.join(' | ')} |`, - ].join('\n') - ); - }); - - test('fast.md log_to_state step reads the existing table header (schema introspection)', () => { - // The fix must inspect the existing STATE.md table header before appending. - // Acceptable signals: reading STATE.md content, grepping for the header line, - // or parsing columns from the header row. - const hasHeaderRead = - // Reads STATE.md to inspect it (awk/sed/grep on the file for header detection) - /grep.*Quick Tasks Completed.*STATE\.md/.test(fastMdContent) || - /awk.*Quick Tasks Completed/.test(fastMdContent) || - /sed.*Quick Tasks Completed/.test(fastMdContent) || - // Reads the header line explicitly (head -n, sed -n, awk NR==) - /head\s+-n/.test(fastMdContent) && /STATE\.md/.test(fastMdContent) || - // Counts pipe separators / columns from existing header - /col.*count|column.*count|count.*col|NF|awk.*\|/.test(fastMdContent) || - // References schema detection in prose - /schema|header|column\s+count|existing.*table/.test(fastMdContent); - - assert.ok( - hasHeaderRead, - [ - 'fast.md log_to_state step does not appear to introspect the existing table schema.', - 'The step must read the STATE.md table header to detect column count before appending.', - 'quick.md Step 7 uses schema-aware matching — fast.md must follow the same discipline.', - ].join('\n') - ); - }); - - test('fast.md log_to_state step references the 5-column quick.md schema', () => { - // The fix must handle the 5-col schema: | # | Description | Date | Commit | Directory | - // Test that all 5 column names appear in the log_to_state step's context. - // Extract the log_to_state step content to scope the check. - const logToStateMatch = fastMdContent.match(/([\s\S]*?)<\/step>/); - assert.ok(logToStateMatch, 'fast.md must contain a element'); - - const stepContent = logToStateMatch[1]; - - // All 5 column names from quick.md Step 7 must be referenced in the step. - for (const col of QUICK_MD_STEP7_COLUMNS) { - assert.ok( - stepContent.toLowerCase().includes(col.toLowerCase()), - [ - `fast.md log_to_state step does not reference column "${col}"`, - `Expected all 5 columns from quick.md Step 7: ${QUICK_MD_STEP7_COLUMNS.join(', ')}`, - `(${QUICK_MD_STEP7_COL_COUNT}-column schema)`, - ].join('\n') - ); - } - }); - - test('fast.md log_to_state step skips STATE.md write on unrecognized schema', () => { - // The fix must not blindly append when the table schema is unknown. - // Check for a guard that skips or logs rather than corrupting the file. - const logToStateMatch = fastMdContent.match(/([\s\S]*?)<\/step>/); - assert.ok(logToStateMatch, 'fast.md must contain a element'); - - const stepContent = logToStateMatch[1]; - - // Must have a skip/guard path for unrecognized schemas - const hasSkipGuard = - /skip/i.test(stepContent) || - /unrecognized|unknown|mismatch/i.test(stepContent) || - /else\b/.test(stepContent) || - /warn|log/i.test(stepContent); - - assert.ok( - hasSkipGuard, - [ - 'fast.md log_to_state step does not appear to guard against unrecognized table schemas.', - 'When the existing STATE.md table does not match an expected schema,', - 'the step must skip the write (with a brief log) rather than append a malformed row.', - ].join('\n') - ); - }); -}); diff --git a/tests/bug-621-plan-phase-gap-analysis-gsd-run.test.cjs b/tests/bug-621-plan-phase-gap-analysis-gsd-run.test.cjs deleted file mode 100644 index 3f64d8cc5..000000000 --- a/tests/bug-621-plan-phase-gap-analysis-gsd-run.test.cjs +++ /dev/null @@ -1,84 +0,0 @@ -// allow-test-rule: source-text-is-the-product -// The post-planning-gaps gap-analysis invocation is deployed workflow text the -// runtime executes; the contract is that it routes through the gsd_run launcher, -// not a hardcoded $HOME path (#621). - -/** - * Regression test for #621: plan-phase gap-analysis must route through gsd_run - * - * Prior to the fix, line 1631 of plan-phase.md hardcoded: - * node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" gap-analysis ... - * twice on the same line, breaking non-default install layouts. - * - * After the fix, both invocations route through gsd_run (the launcher defined - * at line ~34 of the same file that resolves gsd-tools.cjs against - * RUNTIME_DIR / git-toplevel / PATH / $HOME in order). - */ - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const WORKFLOW_PATH = path.join( - __dirname, - '..', - 'gsd-core', - 'workflows', - 'plan-phase.md' -); - -// ─── Fixture ────────────────────────────────────────────────────────────────── - -const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf8'); - -// ─── #621 regression: gap-analysis routes through gsd_run ──────────────────── - -describe('plan-phase workflow: post-planning-gaps gap-analysis uses gsd_run launcher (#621)', () => { - test('gap-analysis dispatches via gsd_run loop render-hooks plan:post (ADR-857 capability gate)', () => { - assert.ok( - workflow.includes('gsd_run loop render-hooks plan:post'), - 'workflow must dispatch gap-analysis via gsd_run loop render-hooks plan:post, not a hardcoded node path or direct gsd_run gap-analysis call' - ); - }); - - test('inner phase_req_ids query also routes through gsd_run', () => { - assert.ok( - workflow.includes('gsd_run query init.plan-phase'), - 'workflow must invoke the inner phase_req_ids query via gsd_run launcher' - ); - }); - - test('no hardcoded node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" invocations remain (#621)', () => { - const hardcodedCount = ( - workflow.match(/node "\$HOME\/\.claude\/gsd-core\/bin\/gsd-tools\.cjs"/g) || [] - ).length; - assert.strictEqual( - hardcodedCount, - 0, - [ - '#621 regression: workflow must not contain any hardcoded', - 'node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" invocations;', - `found ${hardcodedCount}`, - ].join(' ') - ); - }); - - test('post-planning-gaps block still gates on workflow.post_planning_gaps and preserves required args', () => { - const hasGate = workflow.includes('workflow.post_planning_gaps'); - const hasPhaseDir = workflow.includes('gsd_run check ${hook.check.query} "${PHASE_DIR}" "${PHASE_REQ_IDS}"'); - const hasPickArg = workflow.includes('--pick phase_req_ids'); - assert.ok( - hasGate, - 'workflow must still gate the gap-analysis step on workflow.post_planning_gaps config key' - ); - assert.ok( - hasPhaseDir, - 'gap-analysis check dispatch must pass "${PHASE_DIR}" (and "${PHASE_REQ_IDS}") positionally to gsd_run check' - ); - assert.ok( - hasPickArg, - 'inner query must still pass --pick phase_req_ids to extract phase requirement IDs' - ); - }); -}); diff --git a/tests/bug-637-workflow-no-hardcoded-home-tool.test.cjs b/tests/bug-637-workflow-no-hardcoded-home-tool.test.cjs deleted file mode 100644 index 91ce02e47..000000000 --- a/tests/bug-637-workflow-no-hardcoded-home-tool.test.cjs +++ /dev/null @@ -1,67 +0,0 @@ -// allow-test-rule: source-text-is-the-product -// Workflow .md text IS what the runtime loads and the agent executes, so -// asserting on its shell invocations tests the deployed contract directly. -// -// Repo-wide regression guard for #637 (generalizes the plan-phase-only guard -// from #621): NO workflow .md may invoke gsd-tools via a hardcoded -// `node "$HOME/.../gsd-tools.cjs"` path. On a global/shim-only install with no -// project-local runtime, that path can miss a working install, so the step -// reports the tool "not found" instead of resolving it. Every invocation must -// go through the `gsd_run` launcher (defined once per file in the canonical -// preamble, which resolves RUNTIME_DIR → .claude → PATH → $HOME in order). -// -// The parity test (runtime-launcher-parity) guards the retired $GSD_SDK and -// bare /gsd-tools tokens but NOT this hardcoded-node form — which is exactly -// how it survived across plan-phase.md (#621) and three more files (#637). - -'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 WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows'); - -// Hardcoded direct invocation form. Distinct from the canonical preamble, which -// references $HOME only inside a `[ -f "$HOME/..." ]` probe / `GSD_TOOLS=` -// assignment and always invokes `node "$GSD_TOOLS"` — never `node "$HOME/..."`. -const HARDCODED_HOME_INVOCATION = /node\s+"\$HOME\/[^"]*gsd-tools\.cjs"/; - -function collectWorkflowMarkdown(dir) { - const out = []; - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) { - out.push(...collectWorkflowMarkdown(full)); - } else if (entry.isFile() && entry.name.endsWith('.md')) { - out.push(full); - } - } - return out; -} - -describe('bug #637: no workflow .md hardcodes a $HOME gsd-tools invocation', () => { - test('every gsd-core/workflows/**/*.md resolves gsd-tools via gsd_run, not a hardcoded $HOME path', () => { - const files = collectWorkflowMarkdown(WORKFLOWS_DIR); - assert.ok(files.length > 0, 'expected workflow markdown files to exist'); - - const offenders = []; - for (const file of files) { - const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/); - lines.forEach((line, i) => { - if (HARDCODED_HOME_INVOCATION.test(line)) { - offenders.push(`${path.relative(WORKFLOWS_DIR, file)}:${i + 1}: ${line.trim()}`); - } - }); - } - - assert.deepStrictEqual( - offenders, - [], - 'Workflow files must invoke gsd-tools via the resolved `gsd_run` launcher, ' + - 'not a hardcoded `node "$HOME/.../gsd-tools.cjs"` path. Offenders:\n' + - offenders.join('\n'), - ); - }); -}); diff --git a/tests/bug-687-agy-timeout.test.cjs b/tests/bug-687-agy-timeout.test.cjs deleted file mode 100644 index e75711d67..000000000 --- a/tests/bug-687-agy-timeout.test.cjs +++ /dev/null @@ -1,45 +0,0 @@ -// allow-test-rule: source-text-is-the-product -// review.md is a workflow file whose deployed text IS the runtime contract; the -// agy -p invocation cannot be run in CI, so we assert on its content (issue #687). -'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 reviewPath = path.resolve(__dirname, '..', 'gsd-core', 'workflows', 'review.md'); -const read = () => fs.readFileSync(reviewPath, 'utf-8'); - -describe('bug #687: agy print mode must be bounded via its native --print-timeout', () => { - test('invokes agy with its own --print-timeout flag (not an external killer)', () => { - assert.match(read(), /agy --print-timeout \d+s? -p "\$\(cat/, - 'review.md must cap agy through `agy --print-timeout -p …` (the tool\'s own mechanism)'); - }); - - test('discards partial output on non-zero exit so the fallback fires', () => { - const c = read(); - assert.match(c, /_AGY_RC.*-ne 0/, 'review.md must check the agy exit code'); - assert.match(c, /: > \/tmp\/gsd-review-antigravity-/, - 'review.md must truncate the output file when agy timed out / failed'); - }); - - test('agy is bounded only by its own --print-timeout, not an external process killer', () => { - const c = read(); - // Print-mode reviewers invoke the tool directly; agy must self-terminate via - // --print-timeout, never via an external SIGKILL/timeout binary wrapped around it. - assert.doesNotMatch(c, /-s KILL/, 'must not SIGKILL agy from the outside'); - // Any external timeout binary wrapping agy — `timeout 300s agy …`, - // `gtimeout 300 agy …`, `timeout -s KILL 300 agy …`. The lookbehind keeps - // agy's own `--print-timeout` flag from tripping it. - assert.doesNotMatch(c, /(? { - // A bare `agy -p "$(cat …)"` with no cap was the original hang. - assert.doesNotMatch(read(), /^agy -p "\$\(cat/m, - 'review.md must not invoke agy -p without --print-timeout'); - }); -}); diff --git a/tests/bug-936-no-nested-spawner-wrap.test.cjs b/tests/bug-936-no-nested-spawner-wrap.test.cjs deleted file mode 100644 index 27bb440bd..000000000 --- a/tests/bug-936-no-nested-spawner-wrap.test.cjs +++ /dev/null @@ -1,207 +0,0 @@ -'use strict'; -/** - * Structural guard — bug(#936): plan-review-convergence wrapped gsd-plan-phase - * in Agent() at TWO sites (initial planning + replan). On Claude Code, a depth-1 - * Agent has no Agent tool, so plan-phase cannot spawn gsd-planner / gsd-plan-checker - * → the replan loop never works when HIGHs are found. - * - * Fix: run plan-phase INLINE (bare Skill()) from the convergence orchestrator, - * which runs at depth 0 and has Agent available — exactly how autonomous.md, - * manager.md, and discuss-phase-assumptions.md already chain plan-phase. - * - * This guard dynamically derives the set of "spawner" workflows (those containing - * `subagent_type=`) and asserts that NO workflow wraps a spawner inside Agent() - * UNLESS the wrapping block includes a RUNTIME != claude carve-out (the #853 - * pattern already applied to autonomous.md / manager.md). - */ - -// allow-test-rule: source-text-is-the-product -// The workflow markdown IS the runtime instruction — static guards over -// workflow text are the canonical regression-test mechanism (per CONTRIBUTING -// exception matrix and tests/bug-853-bg-dispatch-runtime-gating.test.cjs). - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows'); - -// ── 1. Derive spawner skill names dynamically ────────────────────────────── -// A "spawner" workflow is one that contains `subagent_type=` — it NEEDS the -// Agent tool to run and therefore cannot safely be wrapped in another Agent() -// on Claude Code (where depth-1 agents have no Agent tool). - -// Recursively collect all *.md files under WORKFLOWS_DIR (covers nested fragments -// like discuss-phase/modes/*.md and execute-phase/steps/*.md). -function collectWorkflowFiles(dir) { - const entries = fs.readdirSync(dir, { withFileTypes: true }); - const results = []; - for (const e of entries) { - const fullPath = path.join(dir, e.name); - if (e.isDirectory()) { - results.push(...collectWorkflowFiles(fullPath)); - } else if (e.name.endsWith('.md')) { - results.push({ - name: path.relative(WORKFLOWS_DIR, fullPath), - path: fullPath, - content: fs.readFileSync(fullPath, 'utf8'), - }); - } - } - return results; -} - -const allWorkflowFiles = collectWorkflowFiles(WORKFLOWS_DIR); - -// Map: base-slug → workflow filename (e.g. "plan-phase" → "plan-phase.md") -// Skill() calls use the "gsd-" convention in all workflow files. -// We build BOTH the bare slug set and the gsd-prefixed skill-name set. -const SPAWNER_BASE_SLUGS = new Set( - allWorkflowFiles - .filter((w) => w.content.includes('subagent_type=')) - .map((w) => w.name.replace(/\.md$/, '')) -); - -// Skill invocations use "gsd-" (e.g. gsd-plan-phase, gsd-execute-phase). -// Build the regex from the prefixed names so it actually matches what workflows write. -const SPAWNER_GSD_NAMES = new Set([...SPAWNER_BASE_SLUGS].map((s) => `gsd-${s}`)); - -// Build a regex that matches Skill(skill='gsd-') or Skill(skill="gsd-") -const spawnerPattern = new RegExp( - `Skill\\(\\s*skill=['"](?:${[...SPAWNER_GSD_NAMES].join('|')})['"]`, - 's' -); - -// ── 2. Helper: extract Agent() blocks from a workflow ───────────────────── -// Each block starts at "Agent(" and ends at the balancing ")". We collect -// the text of each such block together with the surrounding context (a 400 -// char window before the block) so we can check for RUNTIME carve-outs. - -function extractAgentBlocks(content) { - const blocks = []; - let pos = 0; - while (pos < content.length) { - const start = content.indexOf('Agent(', pos); - if (start === -1) break; - // Walk forward to find the balancing closing paren - let depth = 0; - let i = start + 'Agent('.length - 1; // at the '(' - for (; i < content.length; i++) { - if (content[i] === '(') depth++; - else if (content[i] === ')') { - depth--; - if (depth === 0) break; - } - } - const end = i + 1; - const blockText = content.slice(start, end); - // Capture context: 400 chars before the block (for RUNTIME gate detection) - const contextBefore = content.slice(Math.max(0, start - 400), start); - blocks.push({ start, end, blockText, contextBefore }); - pos = end; - } - return blocks; -} - -// ── 3. Helper: does a block have a RUNTIME != claude carve-out nearby? ──── -// The #853 pattern looks like: "RUNTIME is `claude`" in a preceding condition -// that switches to inline Skill() instead of the Agent() block. A block is -// considered guarded when the 400-char context window before it (or the block -// body itself for block-internal guards) contains any of these markers. - -function hasRuntimeCarveout(block) { - const haystack = block.contextBefore + block.blockText; - return ( - /RUNTIME[^`\n]{0,30}(?:!=|≠|is not|!==)\s*[`'"]?claude/i.test(haystack) || - /RUNTIME[^`\n]{0,30}claude[^`\n]{0,30}(?:inline|not.*Agent|do NOT)/i.test(haystack) || - /If `RUNTIME` is `claude`/i.test(haystack) || - /On Claude Code.*inline/is.test(haystack) - ); -} - -// ── 4. The guard: scan every workflow for unguarded Agent→spawner wraps ─── - -describe('bug-936 — no workflow wraps a spawner skill inside Agent() without a RUNTIME carve-out', () => { - test('spawner set is non-empty (self-check: subagent_type= grep must find files)', () => { - assert.ok(SPAWNER_BASE_SLUGS.size > 0, `No spawner workflows found in ${WORKFLOWS_DIR} — SPAWNER_BASE_SLUGS derivation is broken`); - // plan-phase must be a spawner (base slug) - assert.ok(SPAWNER_BASE_SLUGS.has('plan-phase'), 'plan-phase.md must be in the spawner set (contains subagent_type=)'); - // gsd-plan-phase must be in the prefixed set used by the regex - assert.ok(SPAWNER_GSD_NAMES.has('gsd-plan-phase'), 'gsd-plan-phase must be in SPAWNER_GSD_NAMES — the prefixed form used in Skill() calls'); - }); - - for (const wf of allWorkflowFiles) { - // Only scan files that have at least one Agent( call - if (!wf.content.includes('Agent(')) continue; - - test(`${wf.name}: no Agent() block wraps a spawner Skill without a RUNTIME carve-out`, () => { - const blocks = extractAgentBlocks(wf.content); - const violations = blocks.filter((b) => { - const wrapsSpawner = spawnerPattern.test(b.blockText); - if (!wrapsSpawner) return false; - return !hasRuntimeCarveout(b); - }); - - assert.deepStrictEqual( - violations.map((v) => v.blockText.slice(0, 120).replace(/\n/g, '\\n')), - [], - `${wf.name} wraps a spawner Skill inside Agent() without a RUNTIME != claude carve-out.\n` + - `Fix: run the spawner Skill inline (bare Skill() call at depth 0) OR add a RUNTIME gate.\n` + - `See: bug #936, tests/bug-853-bg-dispatch-runtime-gating.test.cjs for the guarded pattern.` - ); - }); - } -}); - -// ── 5. Focused regression: plan-review-convergence never wraps plan-phase ─ - -describe('bug-936 — plan-review-convergence runs plan-phase inline, not inside Agent()', () => { - const CONVERGENCE = fs.readFileSync( - path.join(WORKFLOWS_DIR, 'plan-review-convergence.md'), - 'utf8' - ); - - test('plan-review-convergence does NOT wrap gsd-plan-phase inside Agent()', () => { - // The anti-pattern: Agent( block whose body contains Skill(skill='gsd-plan-phase') - const blocks = extractAgentBlocks(CONVERGENCE); - const wrapping = blocks.filter((b) => - /Skill\(\s*skill=['"]gsd-plan-phase['"]/.test(b.blockText) && - !hasRuntimeCarveout(b) - ); - assert.deepStrictEqual( - wrapping.map((v) => v.blockText.slice(0, 120).replace(/\n/g, '\\n')), - [], - 'plan-review-convergence must NOT wrap gsd-plan-phase inside Agent(). ' + - 'Run it inline (bare Skill() at depth 0) so it can spawn gsd-planner/gsd-plan-checker. ' + - 'See: bug #936' - ); - }); - - test('plan-review-convergence calls gsd-plan-phase inline (bare Skill call outside Agent block)', () => { - // After the fix: at least one bare Skill(skill="gsd-plan-phase") must appear - // outside any Agent( block — that is the inline call from the depth-0 orchestrator. - const blocks = extractAgentBlocks(CONVERGENCE); - // Remove all Agent block ranges from the text - let masked = CONVERGENCE; - // Work from end to start so offsets stay valid - const sorted = [...blocks].sort((a, b) => b.start - a.start); - for (const b of sorted) { - masked = masked.slice(0, b.start) + ' '.repeat(b.end - b.start) + masked.slice(b.end); - } - const hasInlineCall = /Skill\(\s*skill=["']gsd-plan-phase["']/.test(masked); - assert.ok( - hasInlineCall, - 'plan-review-convergence must contain at least one bare Skill(skill="gsd-plan-phase") ' + - 'outside any Agent() block — this is the inline call that lets plan-phase spawn its sub-agents. ' + - 'See: bug #936' - ); - }); - - test('plan-review-convergence still wraps gsd-review inside Agent() (leaf — isolation is correct)', () => { - // gsd-review is a leaf (shells out via Bash, no subagent_type) so the Agent wrap is fine and intentional. - const blocks = extractAgentBlocks(CONVERGENCE); - const reviewWrap = blocks.some((b) => /Skill\(\s*skill=['"]gsd-review['"]/.test(b.blockText)); - assert.ok(reviewWrap, 'gsd-review must still be wrapped in Agent() — it is a Bash leaf and isolation is intentional'); - }); -}); diff --git a/tests/chain-flag-plan-phase.test.cjs b/tests/chain-flag-plan-phase.test.cjs index 29c18798b..5c7bba952 100644 --- a/tests/chain-flag-plan-phase.test.cjs +++ b/tests/chain-flag-plan-phase.test.cjs @@ -82,3 +82,79 @@ describe('plan-phase chain flag preservation (#1620)', () => { ); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2388-plan-phase-no-branch-rename.test.cjs — consolidation epic #1969 (B4 #1973) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2388-plan-phase-no-branch-rename (consolidation epic #1969 B4 #1973)", () => { +// allow-test-rule: source-text-is-the-product (see #2388) +// Workflow .md / agent .md / command .md / reference .md files — their text +// IS what the runtime loads. Testing text content tests the deployed contract. +// Per CONTRIBUTING.md exception matrix. + +/** + * Regression test for #2388: plan-phase silently renames feature branch + * when phase slug has changed since the branch was created. + * + * Fix: plan-phase.md must include an explicit instruction not to create, + * rename, or switch git branches during the planning workflow. + */ + +'use strict'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const PLAN_PHASE_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'plan-phase.md'); + +describe('bug-2388: plan-phase must not rename or create git branches', () => { + test('plan-phase.md exists', () => { + assert.ok(fs.existsSync(PLAN_PHASE_PATH), 'plan-phase.md should exist'); + }); + + test('plan-phase.md contains explicit no-branch-rename instruction', () => { + const content = fs.readFileSync(PLAN_PHASE_PATH, 'utf-8'); + // Must say "do not" and mention branch in the context of phase slug/rename + const hasBranchGuard = ( + /do not.{0,80}branch/i.test(content) || + /branch.{0,80}do not/i.test(content) || + /NEVER.{0,80}branch/i.test(content) || + /branch.{0,80}NEVER/i.test(content) + ); + assert.ok( + hasBranchGuard, + 'plan-phase.md must include an explicit instruction not to create or rename git branches' + ); + }); + + test('plan-phase.md mentions phase rename does not affect branch name', () => { + const content = fs.readFileSync(PLAN_PHASE_PATH, 'utf-8'); + // Should explain that a phase rename in ROADMAP.md is plan-level, not git-level + const hasPlanLevelExplanation = ( + content.includes('phase rename') || + content.includes('phase_slug') || + content.includes('branch identity') || + content.includes('branch name') + ); + assert.ok( + hasPlanLevelExplanation, + 'plan-phase.md should clarify that phase slug changes do not change the git branch' + ); + }); + + test('plan-phase.md does not contain git checkout -b instruction', () => { + const content = fs.readFileSync(PLAN_PHASE_PATH, 'utf-8'); + // The workflow should not instruct the LLM to run git checkout -b + assert.ok( + !content.includes('git checkout -b'), + 'plan-phase.md must not instruct LLM to create a new branch via git checkout -b' + ); + }); +}); + }); +} diff --git a/tests/commit-docs-bypass.test.cjs b/tests/commit-docs-bypass.test.cjs index 2ac5328bc..ea73ca021 100644 --- a/tests/commit-docs-bypass.test.cjs +++ b/tests/commit-docs-bypass.test.cjs @@ -96,3 +96,90 @@ describe('commit_docs bypass guard (#1783)', () => { } }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2399-commit-docs-plan-phase.test.cjs — consolidation epic #1969 (B4 #1973) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2399-commit-docs-plan-phase (consolidation epic #1969 B4 #1973)", () => { +// allow-test-rule: source-text-is-the-product (see #2399) +// Workflow .md / agent .md / command .md / reference .md files — their text +// IS what the runtime loads. Testing text content tests the deployed contract. +// Per CONTRIBUTING.md exception matrix. + +/** + * Bug #2399: commit_docs:true is ignored in plan-phase + * + * The plan-phase workflow generates plan artifacts but never commits them even + * when commit_docs is true. A step between 13b and 14 must commit the PLAN.md + * files and updated STATE.md when commit_docs is set. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const PLAN_PHASE_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'plan-phase.md'); + +describe('plan-phase commit_docs support (#2399)', () => { + test('plan-phase.md exists', () => { + assert.ok(fs.existsSync(PLAN_PHASE_PATH), 'gsd-core/workflows/plan-phase.md must exist'); + }); + + test('plan-phase.md has a commit step for plan artifacts', () => { + const content = fs.readFileSync(PLAN_PHASE_PATH, 'utf-8'); + // Must contain a commit call that references PLAN.md files + assert.ok( + content.includes('PLAN.md') && content.includes('commit'), + 'plan-phase.md must include a commit step that references PLAN.md files' + ); + }); + + test('plan-phase.md commit step is gated on commit_docs', () => { + const content = fs.readFileSync(PLAN_PHASE_PATH, 'utf-8'); + // The commit step must be conditional on commit_docs + assert.ok( + content.includes('commit_docs'), + 'plan-phase.md must reference commit_docs to gate the plan commit step' + ); + }); + + test('plan-phase.md commit step references STATE.md', () => { + const content = fs.readFileSync(PLAN_PHASE_PATH, 'utf-8'); + // Should commit STATE.md alongside PLAN.md files + assert.ok( + content.includes('STATE.md'), + 'plan-phase.md commit step should include STATE.md to capture planning completion state' + ); + }); + + test('plan-phase.md has a step 13c that commits plan artifacts', () => { + const content = fs.readFileSync(PLAN_PHASE_PATH, 'utf-8'); + const step13b = content.indexOf('## 13b.'); + const step14 = content.indexOf('## 14.'); + // Look for the step 13c section (or any commit step between 13b and 14) + const step13c = content.indexOf('## 13c.'); + + assert.ok(step13b !== -1, '## 13b. section must exist'); + assert.ok(step14 !== -1, '## 14. section must exist'); + assert.ok(step13c !== -1, '## 13c. step must exist (commit plans step)'); + assert.ok( + step13c > step13b && step13c < step14, + `Step 13c (at ${step13c}) must appear between step 13b (at ${step13b}) and step 14 (at ${step14})` + ); + }); + + test('plan-phase.md uses gsd-sdk query commit for the plan commit', () => { + const content = fs.readFileSync(PLAN_PHASE_PATH, 'utf-8'); + // Must use gsd-sdk query commit (not raw git) so commit_docs guard in gsd-tools is respected + assert.ok( + content.includes('gsd-sdk query commit') || content.includes('gsd-tools') || content.includes('gsd-sdk'), + 'plan-phase.md plan commit step must use gsd-sdk query commit (not raw git commit)' + ); + }); +}); + }); +} diff --git a/tests/discuss-mode.test.cjs b/tests/discuss-mode.test.cjs index b89a7f192..67f952457 100644 --- a/tests/discuss-mode.test.cjs +++ b/tests/discuss-mode.test.cjs @@ -186,3 +186,118 @@ describe('workflow.discuss_mode config', () => { ); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2549-2550-2552-discuss-phase-context.test.cjs — consolidation epic #1969 (B4 #1973) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2549-2550-2552-discuss-phase-context (consolidation epic #1969 B4 #1973)", () => { +// allow-test-rule: source-text-is-the-product (see #2549) +// Workflow .md / agent .md / command .md / reference .md files — their text +// IS what the runtime loads. Testing text content tests the deployed contract. +// Per CONTRIBUTING.md exception matrix. +'use strict'; + + +/** + * Bugs #2549, #2550, #2552: discuss-phase context bloat and cache invalidation. + * + * #2549: load_prior_context must cap prior CONTEXT.md reads (was O(phases)) + * #2550: scout_codebase must select maps by phase type (was always all 7) + * #2552: scout_codebase must not instruct split reads of the same file + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const DISCUSS_PHASE = path.join( + __dirname, '..', 'gsd-core', 'workflows', 'discuss-phase.md', +); +// After the discuss-phase progressive-disclosure split (#717), the scout_codebase phase-type +// table and split-reads warning live in references/scout-codebase.md. +const SCOUT_REF = path.join( + __dirname, '..', 'gsd-core', 'references', 'scout-codebase.md', +); + +function readDiscussContext() { + // Both files are required after the discuss-phase/modes split — fail loudly if either is missing + // rather than silently weakening the regression coverage. + for (const p of [DISCUSS_PHASE, SCOUT_REF]) { + assert.ok(fs.existsSync(p), `Required discuss-phase context source missing: ${p}`); + } + return [DISCUSS_PHASE, SCOUT_REF].map(p => fs.readFileSync(p, 'utf-8')).join('\n'); +} + +describe('discuss-phase context fixes (#2549, #2550, #2552)', () => { + let src; + test('discuss-phase.md source exists', () => { + assert.ok(fs.existsSync(DISCUSS_PHASE), 'discuss-phase.md must exist'); + assert.ok( + fs.existsSync(SCOUT_REF), + 'references/scout-codebase.md must exist after the discuss-phase/modes progressive-disclosure split', + ); + src = readDiscussContext(); + }); + + // ─── #2549: load_prior_context cap ────────────────────────────────────── + test('#2549: load_prior_context must NOT instruct reading ALL prior CONTEXT.md files', () => { + if (!src) src = readDiscussContext(); + assert.ok( + !src.includes('For each CONTEXT.md where phase number < current phase'), + 'load_prior_context must not unboundedly read all prior CONTEXT.md files', + ); + }); + + test('#2549: load_prior_context must reference a bounded read (3 phases or DECISIONS-INDEX)', () => { + // Read ONLY the parent file — `src.includes('3')` against the + // concatenated source can be satisfied by unrelated occurrences of "3" + // in scout-codebase.md (e.g., "3-5 most relevant files"), masking a + // regression where the parent drops the bounded-read instruction. + const parent = fs.readFileSync(DISCUSS_PHASE, 'utf-8'); + const hasBound = /\b(?:most recent|latest|last|up to)\s+3\b[\s\S]{0,160}\bprior CONTEXT\.md\b/i.test(parent); + const hasIndex = parent.includes('DECISIONS-INDEX.md'); + assert.ok( + hasBound || hasIndex, + 'load_prior_context must reference a bounded read (e.g., most recent 3 phases) or DECISIONS-INDEX.md', + ); + }); + + // ─── #2550: scout_codebase phase-type selection ────────────────────────── + test('#2550: scout_codebase must not instruct reading all 7 codebase maps', () => { + if (!src) src = readDiscussContext(); + assert.ok( + !src.includes('Read the most relevant ones (CONVENTIONS.md, STRUCTURE.md, STACK.md based on phase type)'), + 'scout_codebase must not use the old vague "most relevant" instruction without a selection table', + ); + }); + + test('#2550: scout_codebase must include a phase-type-to-maps selection table', () => { + if (!src) src = readDiscussContext(); + // The table maps phase types to specific map selections + assert.ok( + src.includes('Phase type') && src.includes('Read these maps'), + 'scout_codebase must include a phase-type to map-selection table', + ); + // Key phase types must be covered + assert.ok(src.includes('UI') || src.includes('frontend'), 'Table must cover UI/frontend phases'); + assert.ok(src.includes('Backend') || src.includes('API'), 'Table must cover backend phases'); + assert.ok(src.includes('Testing'), 'Table must cover testing phases'); + assert.ok(src.includes('Mixed'), 'Table must have a fallback for mixed/unclear phases'); + }); + + // ─── #2552: no split reads ─────────────────────────────────────────────── + test('#2552: scout_codebase must explicitly prohibit split reads of the same file', () => { + if (!src) src = readDiscussContext(); + const prohibitsSplit = src.includes('split reads') || src.includes('split read'); + assert.ok( + prohibitsSplit, + 'scout_codebase must explicitly warn against split reads (same file, two offsets) that break prompt cache', + ); + }); +}); + }); +} diff --git a/tests/docs-parity-live-registry.test.cjs b/tests/docs-parity-live-registry.test.cjs index eb9e66b1f..3918a9a17 100644 --- a/tests/docs-parity-live-registry.test.cjs +++ b/tests/docs-parity-live-registry.test.cjs @@ -735,3 +735,156 @@ describe('Bug #2954: help.md ↔ commands/gsd/ bidirectional parity', () => { }); }); } + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2950-stale-command-refs.test.cjs — consolidation epic #1969 (B4 #1973) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2950-stale-command-refs (consolidation epic #1969 B4 #1973)", () => { +/** + * Bug #2950: Stale deleted command references in workflow files + * + * Multiple workflow files referenced command names removed in #2790 + * (gsd-add-phase, gsd-insert-phase, gsd-remove-phase, gsd-add-todo, + * gsd-set-profile, gsd-settings-integrations, gsd-settings-advanced, + * gsd-spike-wrap-up, gsd-sketch-wrap-up, gsd-code-review-fix). + * + * Fix: Update every occurrence to the new consolidated forms: + * /gsd:phase (no flag | --insert | --remove) + * /gsd:capture + * /gsd:config (--profile | --integrations | --advanced) + * /gsd:spike --wrap-up + * /gsd:sketch --wrap-up + * /gsd:code-review --fix + */ + +'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 WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows'); + +function read(filename) { + return fs.readFileSync(path.join(WORKFLOWS_DIR, filename), 'utf-8'); +} + +// Deleted command names that must not appear anywhere in the fixed files. +const DELETED_COMMANDS = [ + '/gsd-add-phase', + '/gsd-insert-phase', + '/gsd-remove-phase', + '/gsd-add-todo', + '/gsd-set-profile', + '/gsd-settings-integrations', + '/gsd-settings-advanced', + '/gsd-spike-wrap-up', + '/gsd-sketch-wrap-up', + '/gsd-code-review-fix', +]; + +// Per-file assertions: [file, deletedCmd, newForm] +const FILE_ASSERTIONS = [ + // help.md → moved to help/modes/full.md in #3039 tiered-help refactor + ['help/modes/full.md', '/gsd-add-phase', '/gsd:phase "Add admin dashboard"'], + ['help/modes/full.md', '/gsd-insert-phase', '/gsd:phase --insert 7 "Fix critical auth bug"'], + ['help/modes/full.md', '/gsd-remove-phase', '/gsd:phase --remove 17'], + ['help/modes/full.md', '/gsd-spike-wrap-up', '/gsd:spike --wrap-up'], + ['help/modes/full.md', '/gsd-sketch-wrap-up', '/gsd:sketch --wrap-up'], + ['help/modes/full.md', '/gsd-add-todo', '/gsd:capture'], + ['help/modes/full.md', '/gsd-set-profile', '/gsd:config --profile budget'], + + // do.md + ['do.md', '/gsd-spike-wrap-up', '/gsd:spike --wrap-up'], + ['do.md', '/gsd-sketch-wrap-up', '/gsd:sketch --wrap-up'], + ['do.md', '/gsd-add-phase', '/gsd:phase'], + ['do.md', '/gsd-add-todo', '/gsd:capture'], + + // settings.md + ['settings.md', '/gsd-code-review-fix', '/gsd:code-review --fix'], + ['settings.md', '/gsd-settings-integrations', '/gsd:config --integrations'], + ['settings.md', '/gsd-set-profile', '/gsd:config --profile'], + ['settings.md', '/gsd-settings-advanced', '/gsd:config --advanced'], + + // discuss-phase.md + ['discuss-phase.md', '/gsd-spike-wrap-up', '/gsd:spike --wrap-up'], + ['discuss-phase.md', '/gsd-sketch-wrap-up', '/gsd:sketch --wrap-up'], + + // new-project.md + ['new-project.md', '/gsd-spike-wrap-up', '/gsd:spike --wrap-up'], + ['new-project.md', '/gsd-sketch-wrap-up', '/gsd:sketch --wrap-up'], + + // plan-phase.md + ['plan-phase.md', '/gsd-insert-phase', '/gsd:phase --insert'], + + // spike.md + ['spike.md', '/gsd-spike-wrap-up', '/gsd:spike --wrap-up'], + + // sketch.md + ['sketch.md', '/gsd-sketch-wrap-up', '/gsd:sketch --wrap-up'], +]; + +describe('bug #2950: stale deleted-command references removed from workflow files', () => { + // Build a map of file → content to avoid re-reading + const files = [...new Set(FILE_ASSERTIONS.map(([f]) => f))]; + const contentMap = {}; + for (const f of files) { + contentMap[f] = read(f); + } + + // For each (file, deletedCmd) pair, assert the old name is absent + for (const [file, deletedCmd] of FILE_ASSERTIONS) { + test(`${file}: does not contain deleted command "${deletedCmd}"`, () => { + const content = contentMap[file]; + assert.ok( + !content.includes(deletedCmd), + `${file} still contains deleted command "${deletedCmd}" — update to new form` + ); + }); + } + + // For each (file, deletedCmd, newForm) triple, assert the new form is present + for (const [file, , newForm] of FILE_ASSERTIONS) { + test(`${file}: contains new form "${newForm}"`, () => { + const content = contentMap[file]; + assert.ok( + content.includes(newForm), + `${file} is missing expected new form "${newForm}"` + ); + }); + } + + // Blanket check: no affected workflow file contains any of the deleted command names + // (catches any we might have missed in per-file assertions above) + const affectedFiles = [ + 'help.md', + 'help/modes/full.md', + 'help/modes/default.md', + 'help/modes/brief.md', + 'help/modes/topic.md', + 'do.md', + 'settings.md', + 'discuss-phase.md', + 'new-project.md', + 'plan-phase.md', + 'spike.md', + 'sketch.md', + ]; + + for (const file of affectedFiles) { + const content = read(file); + for (const deleted of DELETED_COMMANDS) { + test(`${file}: blanket check — "${deleted}" not present`, () => { + assert.ok( + !content.includes(deleted), + `${file} contains deleted command "${deleted}"` + ); + }); + } + } +}); + }); +} diff --git a/tests/enh-2430-learnings-consumption.test.cjs b/tests/enh-2430-learnings-consumption.test.cjs deleted file mode 100644 index d1a70501f..000000000 --- a/tests/enh-2430-learnings-consumption.test.cjs +++ /dev/null @@ -1,238 +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 #2430 — LEARNINGS.md consumption loop. - * - * Part A: plan-phase.md cross-phase context load includes LEARNINGS.md - * Part B: transition.md graduation_scan step + graduation.md helper - */ - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const WORKFLOWS_DIR = path.join(__dirname, '../gsd-core/workflows'); - -function readWorkflow(name) { - return fs.readFileSync(path.join(WORKFLOWS_DIR, name), 'utf-8'); -} - -describe('enh-2430 Part A — plan-phase LEARNINGS.md context load', () => { - let content; - - test('plan-phase.md includes LEARNINGS.md in cross-phase context load', () => { - content = readWorkflow('plan-phase.md'); - assert.ok( - content.includes('LEARNINGS.md files from the 3 most recent completed phases'), - 'plan-phase.md must mention LEARNINGS.md in cross-phase context block' - ); - }); - - test('plan-phase.md LEARNINGS load is inside the 1M context-window gate', () => { - content = content || readWorkflow('plan-phase.md'); - const windowBlock = content.match(/\$\{CONTEXT_WINDOW >= 500000[\s\S]*?` : ''\}/); - assert.ok(windowBlock, 'CONTEXT_WINDOW gate block must exist'); - assert.ok( - windowBlock[0].includes('LEARNINGS.md'), - 'LEARNINGS.md load must be inside the CONTEXT_WINDOW >= 500000 gate' - ); - }); - - test('plan-phase.md source attribution mentioned for LEARNINGS load', () => { - content = content || readWorkflow('plan-phase.md'); - assert.ok( - content.includes('[from Phase N LEARNINGS]') || content.includes('source attribution'), - 'plan-phase.md must document source attribution for loaded LEARNINGS.md content' - ); - }); - - test('plan-phase.md handles missing LEARNINGS.md gracefully (silent skip)', () => { - content = content || readWorkflow('plan-phase.md'); - assert.ok( - content.includes('skip silently if a phase has no LEARNINGS.md') || - content.includes('skip silently'), - 'plan-phase.md must document silent skip when LEARNINGS.md is absent' - ); - }); - - test('plan-phase.md LEARNINGS load includes Depends-on chain', () => { - content = content || readWorkflow('plan-phase.md'); - content.match(/Depends on.*?(\n.*?)+/); - assert.ok( - content.includes('LEARNINGS.md from any phases listed in'), - 'plan-phase.md must load LEARNINGS.md for Depends on chain phases' - ); - }); - - test('plan-phase.md specifies context budget limit for LEARNINGS', () => { - content = content || readWorkflow('plan-phase.md'); - assert.ok( - content.includes('15%') || content.includes('drop oldest'), - 'plan-phase.md must specify budget limit and truncation strategy for LEARNINGS' - ); - }); -}); - -describe('enh-2430 Part B — graduation_scan in transition.md', () => { - let content; - - test('transition.md contains graduation_scan step', () => { - content = readWorkflow('transition.md'); - assert.ok( - content.includes('graduation_scan'), - 'transition.md must contain graduation_scan step' - ); - }); - - test('graduation_scan is placed after evolve_project step', () => { - content = content || readWorkflow('transition.md'); - const evolvePos = content.indexOf('name="evolve_project"'); - const graduationPos = content.indexOf('name="graduation_scan"'); - assert.ok(evolvePos >= 0, 'evolve_project step must exist'); - assert.ok(graduationPos >= 0, 'graduation_scan step must exist'); - assert.ok( - graduationPos > evolvePos, - 'graduation_scan must appear after evolve_project in transition.md' - ); - }); - - test('graduation_scan is non-blocking (transition continues regardless)', () => { - content = content || readWorkflow('transition.md'); - const scanBlock = content.match(/name="graduation_scan"[\s\S]*?<\/step>/); - assert.ok(scanBlock, 'graduation_scan step must be parseable'); - assert.ok( - scanBlock[0].includes('non-blocking') || scanBlock[0].includes('always non-blocking'), - 'graduation_scan must be documented as non-blocking' - ); - }); - - test('graduation_scan delegates to graduation.md helper', () => { - content = content || readWorkflow('transition.md'); - assert.ok( - content.includes('graduation.md'), - 'graduation_scan must reference graduation.md helper workflow' - ); - }); -}); - -describe('enh-2430 Part B — graduation.md helper workflow', () => { - let content; - - test('graduation.md exists', () => { - content = readWorkflow('graduation.md'); - assert.ok(content.length > 0, 'graduation.md must exist and be non-empty'); - }); - - test('graduation.md documents features.graduation config flag', () => { - content = content || readWorkflow('graduation.md'); - assert.ok( - content.includes('features.graduation'), - 'graduation.md must document features.graduation config flag' - ); - }); - - test('graduation.md documents graduation_window config', () => { - content = content || readWorkflow('graduation.md'); - assert.ok( - content.includes('graduation_window'), - 'graduation.md must document features.graduation_window config' - ); - }); - - test('graduation.md documents graduation_threshold config', () => { - content = content || readWorkflow('graduation.md'); - assert.ok( - content.includes('graduation_threshold'), - 'graduation.md must document features.graduation_threshold config' - ); - }); - - test('graduation.md specifies HITL: Promote / Defer / Dismiss', () => { - content = content || readWorkflow('graduation.md'); - assert.ok(content.includes('Promote'), 'graduation.md must document Promote action'); - assert.ok(content.includes('Defer'), 'graduation.md must document Defer action'); - assert.ok(content.includes('Dismiss'), 'graduation.md must document Dismiss action'); - }); - - test('graduation.md specifies category→target routing', () => { - content = content || readWorkflow('graduation.md'); - assert.ok( - content.includes('PROJECT.md') && content.includes('PATTERNS.md'), - 'graduation.md must route categories to appropriate target files' - ); - }); - - test('graduation.md specifies graduation_backlog in STATE.md', () => { - content = content || readWorkflow('graduation.md'); - assert.ok( - content.includes('graduation_backlog'), - 'graduation.md must document STATE.md graduation_backlog for Defer/Dismiss' - ); - }); - - test('graduation.md skips items with graduated: annotation', () => { - content = content || readWorkflow('graduation.md'); - assert.ok( - content.includes('graduated:') || content.includes('Graduated:'), - 'graduation.md must skip already-graduated items' - ); - }); - - test('graduation.md has silent no-op for first phase / insufficient data', () => { - content = content || readWorkflow('graduation.md'); - assert.ok( - content.includes('no-op') || content.includes('silent'), - 'graduation.md must silently no-op when there is insufficient data' - ); - }); - - test('graduation.md specifies Defer-all shorthand (A key)', () => { - content = content || readWorkflow('graduation.md'); - assert.ok( - content.includes('Defer all') || content.includes('[Defer all]'), - 'graduation.md must document the Defer all shorthand for first-run batches' - ); - }); -}); - -describe('enh-2430 — extract-learnings.md graduated: field', () => { - test('extract-learnings.md documents optional graduated: annotation', () => { - const content = readWorkflow('extract-learnings.md'); - assert.ok( - content.includes('graduated:') || content.includes('Graduated:'), - 'extract-learnings.md must document optional graduated: field' - ); - }); - - test('extract-learnings.md clarifies graduated: is written only by graduation workflow', () => { - const content = readWorkflow('extract-learnings.md'); - assert.ok( - content.includes('graduation workflow') || content.includes('graduation.md'), - 'extract-learnings.md must clarify that graduated: is written only by graduation.md' - ); - }); -}); - -describe('enh-2430 — INVENTORY sync', () => { - test('INVENTORY.md lists graduation.md', () => { - const inventory = fs.readFileSync( - path.join(__dirname, '../docs/INVENTORY.md'), 'utf-8' - ); - assert.ok(inventory.includes('graduation.md'), 'INVENTORY.md must list graduation.md'); - }); - - test('INVENTORY-MANIFEST.json includes graduation.md', () => { - const manifest = JSON.parse( - fs.readFileSync(path.join(__dirname, '../docs/INVENTORY-MANIFEST.json'), 'utf-8') - ); - assert.ok( - manifest.families.workflows.includes('graduation.md'), - 'INVENTORY-MANIFEST.json must include graduation.md in workflows array' - ); - }); -}); diff --git a/tests/enh-2433-todo-phase-linking.test.cjs b/tests/enh-2433-todo-phase-linking.test.cjs deleted file mode 100644 index e0fa47217..000000000 --- a/tests/enh-2433-todo-phase-linking.test.cjs +++ /dev/null @@ -1,87 +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 gsd-new-milestone todo-to-phase linking (#2433). - * Verifies the workflow text contains the correct linking and auto-close steps. - */ - -const { test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const ROOT = path.resolve(__dirname, '..'); - -const NEW_MILESTONE = fs.readFileSync( - path.join(ROOT, 'gsd-core/workflows/new-milestone.md'), 'utf-8' -); -const EXECUTE_PHASE = fs.readFileSync( - path.join(ROOT, 'gsd-core/workflows/execute-phase.md'), 'utf-8' -); - -test('new-milestone.md: step 10.5 links pending todos to roadmap phases', () => { - assert.ok(NEW_MILESTONE.includes('10.5'), 'step 10.5 should exist'); - assert.ok(NEW_MILESTONE.includes('resolves_phase'), 'should reference resolves_phase field'); - assert.ok(NEW_MILESTONE.includes('.planning/todos/pending'), 'should scan pending todos directory'); -}); - -test('new-milestone.md: todo linking runs after roadmap commit', () => { - const roadmapCommitIdx = NEW_MILESTONE.indexOf('docs: create milestone v[X.Y] roadmap'); - const step105Idx = NEW_MILESTONE.indexOf('10.5. Link Pending Todos'); - const step11Idx = NEW_MILESTONE.indexOf('## 11. Done'); - assert.ok(roadmapCommitIdx < step105Idx, 'step 10.5 should come after roadmap commit'); - assert.ok(step105Idx < step11Idx, 'step 10.5 should come before step 11'); -}); - -test('new-milestone.md: todo linking is best-effort and leaves unmatched todos unmodified', () => { - assert.ok(NEW_MILESTONE.includes('best-effort'), 'should describe best-effort matching'); - assert.ok(NEW_MILESTONE.includes('unmatched'), 'should mention leaving unmatched todos alone'); - assert.ok(NEW_MILESTONE.includes('confident match'), 'should gate on confident match'); -}); - -test('new-milestone.md: step 10.5 commits tagged todos', () => { - // After #3797 architectural fix, callsites use gsd_run - assert.ok(NEW_MILESTONE.includes('gsd_run query commit'), 'should commit tagged todos'); - assert.ok(NEW_MILESTONE.includes('resolves_phase after milestone'), 'commit message should mention resolves_phase'); -}); - -test('new-milestone.md: success_criteria includes todo linking', () => { - assert.ok(NEW_MILESTONE.includes('resolves_phase: N'), 'success_criteria should mention resolves_phase tagging'); -}); - -test('execute-phase.md: close_phase_todos step exists', () => { - assert.ok(EXECUTE_PHASE.includes('close_phase_todos'), 'close_phase_todos step should exist'); - assert.ok(EXECUTE_PHASE.includes('resolves_phase'), 'should check resolves_phase in todos'); -}); - -test('execute-phase.md: auto-close moves todos to completed directory', () => { - assert.ok(EXECUTE_PHASE.includes('.planning/todos/completed'), 'should move to completed dir'); - assert.ok(EXECUTE_PHASE.includes('.planning/todos/pending'), 'should scan pending dir'); - assert.ok(EXECUTE_PHASE.includes('mv "$TODO_FILE" "$COMPLETED_DIR/"'), 'should use mv to move files'); -}); - -test('execute-phase.md: close_phase_todos runs after update_roadmap', () => { - const updateRoadmapIdx = EXECUTE_PHASE.indexOf('name="update_roadmap"'); - const closeTodosIdx = EXECUTE_PHASE.indexOf('name="close_phase_todos"'); - assert.ok(updateRoadmapIdx < closeTodosIdx, 'close_phase_todos should run after update_roadmap'); -}); - -test('execute-phase.md: auto-close never blocks phase completion', () => { - const closeTodosSection = EXECUTE_PHASE.slice( - EXECUTE_PHASE.indexOf('name="close_phase_todos"'), - EXECUTE_PHASE.indexOf('name="update_project_md"') - ); - assert.ok( - closeTodosSection.includes('never blocks') || closeTodosSection.includes('additive'), - 'close_phase_todos should be non-blocking' - ); -}); - -test('execute-phase.md: awk extracts resolves_phase from YAML frontmatter', () => { - assert.ok(EXECUTE_PHASE.includes('awk'), 'should use awk for frontmatter extraction'); - assert.ok(EXECUTE_PHASE.includes('resolves_phase:'), 'awk pattern should match resolves_phase key'); -}); diff --git a/tests/enh-773-codex-exec-automation-flags.test.cjs b/tests/enh-773-codex-exec-automation-flags.test.cjs deleted file mode 100644 index 55c8cf7e4..000000000 --- a/tests/enh-773-codex-exec-automation-flags.test.cjs +++ /dev/null @@ -1,140 +0,0 @@ -'use strict'; - -// allow-test-rule: source-text-is-the-product -// Workflow markdown is runtime contract; these assertions verify that -// automated codex exec invocations carry the correct automation flags. - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -describe('enh-773: automated codex exec invocations include --ephemeral and --dangerously-bypass-hook-trust', () => { - const workflow = fs.readFileSync( - path.join(process.cwd(), 'gsd-core', 'workflows', 'review.md'), - 'utf8' - ); - - // Extract codex exec INVOCATION lines from code fences. The #1115 capability - // probe (`codex exec --help | grep …`) is not an automation invocation, so it - // is excluded from the per-invocation flag assertions below. - const codexExecLines = workflow - .split(/\r?\n/) - .filter((line) => line.includes('codex exec') && !line.includes('codex exec --help')); - - test('review.md contains at least one codex exec invocation', () => { - assert.ok( - codexExecLines.length > 0, - 'review.md must contain at least one codex exec invocation' - ); - }); - - test('every codex exec invocation includes --ephemeral', () => { - for (const line of codexExecLines) { - assert.ok( - line.includes('--ephemeral'), - `codex exec invocation is missing --ephemeral:\n ${line.trim()}` - ); - } - }); - - test('#1115: the hook-trust bypass is capability-gated, not passed unconditionally', () => { - // --dangerously-bypass-hook-trust only exists on codex-cli >= 0.137.0. It must - // be probed (`codex exec --help | grep`) and applied via $CODEX_BYPASS_FLAG so - // older installs do not fail with "unexpected argument" (a silent empty review). - assert.ok( - /codex exec --help[^\r\n]*grep[^\r\n]*--dangerously-bypass-hook-trust/.test(workflow), - 'review.md must capability-probe --dangerously-bypass-hook-trust via `codex exec --help | grep`' - ); - assert.ok( - workflow.includes('CODEX_BYPASS_FLAG="--dangerously-bypass-hook-trust"'), - 'the probe must set CODEX_BYPASS_FLAG to the flag when the CLI supports it' - ); - for (const line of codexExecLines) { - assert.ok( - line.includes('$CODEX_BYPASS_FLAG'), - `codex exec invocation must apply the capability-gated $CODEX_BYPASS_FLAG, not an unconditional flag:\n ${line.trim()}` - ); - // …and must NOT also pass the literal flag (that would reintroduce #1115). - assert.ok( - !line.includes('--dangerously-bypass-hook-trust'), - `codex exec invocation must not pass the literal --dangerously-bypass-hook-trust (use the gated $CODEX_BYPASS_FLAG):\n ${line.trim()}` - ); - } - }); - - test('#1115: codex review failures are surfaced, not silently swallowed', () => { - // stderr must be captured (not discarded to /dev/null) and an empty output - // must be replaced with a diagnostic, so a broken reviewer is reported. - for (const line of codexExecLines) { - assert.ok( - !line.includes('2>/dev/null'), - `codex exec must not discard stderr to /dev/null:\n ${line.trim()}` - ); - } - assert.ok( - /\[ ! -s \/tmp\/gsd-review-codex-\{phase\}\.md \]/.test(workflow), - 'review.md must guard against an empty codex review output and surface the failure' - ); - }); - - test('--ephemeral appears before the prompt argument (flag ordering)', () => { - for (const line of codexExecLines) { - const ephemeralPos = line.indexOf('--ephemeral'); - const promptPos = line.indexOf(' - '); - if (promptPos === -1) continue; // no stdin prompt arg on this line - assert.ok( - ephemeralPos < promptPos, - `--ephemeral must appear before the stdin prompt argument:\n ${line.trim()}` - ); - } - }); - - test('--skip-git-repo-check is preserved alongside automation flags', () => { - for (const line of codexExecLines) { - assert.ok( - line.includes('--skip-git-repo-check'), - `codex exec invocation lost --skip-git-repo-check:\n ${line.trim()}` - ); - } - }); -}); - -describe('#1698 regression: codex review is captured via --output-last-message, not stdout', () => { - // WHY: on some platforms (Windows) `codex exec` writes process-teardown output - // to stdout *after* the final agent message. A `> FILE` stdout redirect appends - // that noise to a non-empty file, so it slips past the `[ ! -s … ]` empty-output - // guard and downstream consumers (severity extraction, the - // plan-review-convergence "concerns resolved?" gate) parse a polluted review. - // `-o/--output-last-message ` writes only the final message — robust on - // every platform — so each codex invocation must capture via -o and discard stdout. - const workflow = fs.readFileSync( - path.join(process.cwd(), 'gsd-core', 'workflows', 'review.md'), - 'utf8' - ); - const codexExecLines = workflow - .split(/\r?\n/) - .filter((line) => line.includes('codex exec') && !line.includes('codex exec --help')); - - test('every codex exec invocation captures the review via -o ', () => { - for (const line of codexExecLines) { - assert.ok( - /\s-o\s+\/tmp\/gsd-review-codex-\{phase\}\.md\b/.test(line), - `codex exec invocation must capture the review via -o /tmp/gsd-review-codex-{phase}.md:\n ${line.trim()}` - ); - } - }); - - test('no codex exec invocation redirects stdout into the review file', () => { - for (const line of codexExecLines) { - assert.ok( - !/>\s*\/tmp\/gsd-review-codex-\{phase\}\.md\b/.test(line), - `codex exec must not redirect stdout into the review file (teardown noise pollutes it); use -o + >/dev/null:\n ${line.trim()}` - ); - assert.ok( - />\s*\/dev\/null\b/.test(line), - `codex exec must discard stdout to /dev/null so teardown output is not captured:\n ${line.trim()}` - ); - } - }); -}); diff --git a/tests/execute-phase-active-flags.test.cjs b/tests/execute-phase-active-flags.test.cjs index 35d4acb30..d2ee66fdc 100644 --- a/tests/execute-phase-active-flags.test.cjs +++ b/tests/execute-phase-active-flags.test.cjs @@ -65,3 +65,355 @@ describe('execute-phase command: active flags are explicit', () => { ); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2396-makefile-test-priority.test.cjs — consolidation epic #1969 (B4 #1973) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2396-makefile-test-priority (consolidation epic #1969 B4 #1973)", () => { +// allow-test-rule: source-text-is-the-product (see #2396) +// Workflow .md / agent .md / command .md / reference .md files — their text +// IS what the runtime loads. Testing text content tests the deployed contract. +// Per CONTRIBUTING.md exception matrix. + +/** + * Regression test for #2396: hardcoded host-level test commands bypass + * container-only project Makefiles. + * + * Fix: execute-phase.md, verify-phase.md, and audit-fix.md must check for + * Makefile with a test target (and other wrappers) before falling through + * to hardcoded language-sniffed commands. + */ + +'use strict'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const EXECUTE_PHASE_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md'); +const VERIFY_PHASE_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'verify-phase.md'); +const AUDIT_FIX_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'audit-fix.md'); + +function assertMakefileCheckBeforeNpmTest(filePath, label) { + const content = fs.readFileSync(filePath, 'utf-8'); + + // Must check for Makefile with test target + const hasMakefileCheck = /Makefile.*grep.*test:|grep.*test:.*Makefile/s.test(content) || + (content.includes('Makefile') && content.includes('"^test:"')); + assert.ok( + hasMakefileCheck, + `${label}: must check for Makefile with test: target before falling through to hardcoded commands` + ); + + // make test must appear before npm test in the file + const makeTestIdx = content.indexOf('make test'); + const npmTestIdx = content.indexOf('npm test'); + assert.ok(makeTestIdx !== -1, `${label}: must contain "make test"`); + assert.ok(npmTestIdx !== -1, `${label}: must still contain "npm test" as fallback`); + assert.ok( + makeTestIdx < npmTestIdx, + `${label}: "make test" must appear before "npm test" (Makefile takes priority)` + ); +} + +function assertConfigGetBeforeMakefile(filePath, label) { + const content = fs.readFileSync(filePath, 'utf-8'); + // Must check workflow.test_command config before Makefile sniff. + // Verify within each bash code block: the workflow.test_command lookup + // appears before the Makefile grep in the same block. + assert.ok( + content.includes('workflow.test_command'), + `${label}: must check workflow.test_command config before Makefile/language sniff` + ); + + // Extract bash blocks to check ordering within each block. + // Use the actual Makefile test ([ -f "Makefile" ]) not just the word "Makefile" + // (which appears in comments before the config-get call). + const bashBlockRe = /```bash([\s\S]*?)```/g; + let match; + let anyBlockCorrectlyOrdered = false; + while ((match = bashBlockRe.exec(content)) !== null) { + const block = match[1]; + if (block.includes('workflow.test_command') && block.includes('[ -f "Makefile"')) { + const configIdx = block.indexOf('workflow.test_command'); + const makefileIdx = block.indexOf('[ -f "Makefile"'); + if (configIdx < makefileIdx) { + anyBlockCorrectlyOrdered = true; + break; + } + } + } + assert.ok( + anyBlockCorrectlyOrdered, + `${label}: within a bash block, workflow.test_command config check must appear before Makefile test ([ -f "Makefile" ])` + ); +} + +describe('bug-2396: Makefile test target must take priority over hardcoded commands', () => { + test('execute-phase.md exists', () => { + assert.ok(fs.existsSync(EXECUTE_PHASE_PATH), 'execute-phase.md should exist'); + }); + + test('verify-phase.md exists', () => { + assert.ok(fs.existsSync(VERIFY_PHASE_PATH), 'verify-phase.md should exist'); + }); + + test('audit-fix.md exists', () => { + assert.ok(fs.existsSync(AUDIT_FIX_PATH), 'audit-fix.md should exist'); + }); + + test('execute-phase.md: Makefile check precedes npm test (post-merge gate)', () => { + assertMakefileCheckBeforeNpmTest(EXECUTE_PHASE_PATH, 'execute-phase.md'); + }); + + test('verify-phase.md: Makefile check precedes npm test', () => { + assertMakefileCheckBeforeNpmTest(VERIFY_PHASE_PATH, 'verify-phase.md'); + }); + + test('audit-fix.md: Makefile check precedes npm test', () => { + assertMakefileCheckBeforeNpmTest(AUDIT_FIX_PATH, 'audit-fix.md'); + }); + + test('execute-phase.md: workflow.test_command config checked first (within bash block)', () => { + assertConfigGetBeforeMakefile(EXECUTE_PHASE_PATH, 'execute-phase.md'); + }); + + test('verify-phase.md: workflow.test_command config checked first (within bash block)', () => { + assertConfigGetBeforeMakefile(VERIFY_PHASE_PATH, 'verify-phase.md'); + }); + + test('audit-fix.md: workflow.test_command config checked first (within bash block)', () => { + assertConfigGetBeforeMakefile(AUDIT_FIX_PATH, 'audit-fix.md'); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2516-inherit-model-execute-phase.test.cjs — consolidation epic #1969 (B4 #1973) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2516-inherit-model-execute-phase (consolidation epic #1969 B4 #1973)", () => { +// allow-test-rule: source-text-is-the-product (see #2516) +// Workflow .md / agent .md / command .md / reference .md files — their text +// IS what the runtime loads. Testing text content tests the deployed contract. +// Per CONTRIBUTING.md exception matrix. + +/** + * Regression test for bug #2516 + * + * When `.planning/config.json` has `model_profile: "inherit"`, the + * `init.execute-phase` query returns `executor_model: "inherit"`. The + * execute-phase workflow was passing this literal string directly to the + * Task tool via `model="{executor_model}"`, causing Task to fall back to + * its default model instead of inheriting the orchestrator model. + * + * Fix: the workflow must document that when `executor_model` is `"inherit"`, + * the `model=` parameter must be OMITTED from Task() calls entirely. + * Omitting `model=` causes Claude Code to inherit the current orchestrator + * model automatically. + */ + +'use strict'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const WORKFLOW_PATH = path.join( + __dirname, + '..', + 'gsd-core', + 'workflows', + 'execute-phase.md' +); + +describe('bug #2516: executor_model "inherit" must not be passed literally to Task()', () => { + test('workflow file exists', () => { + assert.ok(fs.existsSync(WORKFLOW_PATH), 'gsd-core/workflows/execute-phase.md should exist'); + }); + + test('workflow contains instructions for handling the "inherit" case', () => { + assert.ok(fs.existsSync(WORKFLOW_PATH), 'gsd-core/workflows/execute-phase.md should exist'); + const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8'); + const hasInheritInstruction = + content.includes('"inherit"') && + (content.includes('omit') || content.includes('Omit') || content.includes('omitting') || content.includes('Omitting')); + assert.ok( + hasInheritInstruction, + 'execute-phase.md must document that when executor_model is "inherit", ' + + 'the model= parameter must be omitted from Task() calls. ' + + 'Found "inherit" mention: ' + content.includes('"inherit"') + '. ' + + 'Found omit mention: ' + (content.includes('omit') || content.includes('Omit')) + ); + }); + + test('workflow does not instruct passing model="inherit" literally to Task', () => { + assert.ok(fs.existsSync(WORKFLOW_PATH), 'gsd-core/workflows/execute-phase.md should exist'); + const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8'); + // The workflow must not have an unconditional model="{executor_model}" template + // that would pass "inherit" through. It should document conditional logic. + const hasConditionalModelParam = + content.includes('inherit') && + ( + content.includes('Only set `model=`') || + content.includes('only set `model=`') || + content.includes('Only set model=') || + content.includes('omit the `model=`') || + content.includes('omit the model=') || + content.includes('omit `model=`') || + content.includes('omit model=') + ); + const lines = content.split(/\r?\n/); + const hasLiteralInheritInTask = lines.some(line => { + if (!/model\s*=\s*["']inherit["']/.test(line)) return false; + // Exclude instructional/explanatory lines that document what NOT to do + return !/\b(not|NOT|don'?t|do not|DO NOT|never|NEVER)\b/.test(line); + }); + assert.ok( + !hasLiteralInheritInTask, + 'execute-phase workflow must not pass literal "inherit" string to Task() model parameter' + ); + assert.ok( + hasConditionalModelParam && !hasLiteralInheritInTask, + 'execute-phase.md must conditionally omit model= when executor_model is "inherit", never pass it literally. ' + + 'The unconditional model="{executor_model}" template would pass the literal ' + + 'string "inherit" to Task(), which falls back to the default model instead ' + + 'of the orchestrator model (root cause of #2516).' + ); + // Guard against a future contributor adding an unconditional model="{executor_model}" + // template alongside the conditional docs — that would pass "inherit" literally to Task(). + const hasUnsafeTemplate = lines.some(line => { + if (!/model\s*=\s*['"]\{executor_model\}['"]/.test(line)) return false; + return !/\b(not|NOT|do not|DO NOT|don'?t|never|NEVER|omit)\b/i.test(line); + }); + assert.ok(!hasUnsafeTemplate, + 'execute-phase.md must not contain an unconditional model="{executor_model}" template — ' + + 'it would pass "inherit" literally to Task() when executor_model is "inherit"' + ); + }); + + test('workflow documents that omitting model= causes inheritance from orchestrator', () => { + assert.ok(fs.existsSync(WORKFLOW_PATH), 'gsd-core/workflows/execute-phase.md should exist'); + const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8'); + const hasInheritanceExplanation = + content.includes('inherit') && + ( + content.includes('orchestrator model') || + content.includes('orchestrator\'s model') || + content.includes('inherits the') || + content.includes('inherit the current') + ); + assert.ok( + hasInheritanceExplanation, + 'execute-phase.md must explain that omitting model= causes Claude Code to ' + + 'inherit the current orchestrator model — this is the mechanism that makes ' + + '"inherit" work correctly.' + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2002-offer-next-context.test.cjs — consolidation epic #1969 (B4 #1973) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2002-offer-next-context (consolidation epic #1969 B4 #1973)", () => { +/** + * Regression tests for bug #2002 + * + * offer_next in execute-phase.md must present conditional next steps + * based on whether CONTEXT.md already exists for the next phase. + * The previous flat list offered all options equally with no primary + * recommendation, leaving agents without guidance on the correct first step. + * + * Fixed: offer_next now checks for {next}-CONTEXT.md in the phase directory. + * - If CONTEXT.md is missing: primary suggestion is /gsd-discuss-phase + * - If CONTEXT.md exists: primary suggestion is /gsd-plan-phase + */ + +'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 workflowPath = path.resolve( + __dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md' +); + +describe('bug #2002: offer_next checks CONTEXT.md before suggesting next step', () => { + let content; + + // Read once — all tests share the same file content + test('setup: workflow file is readable', () => { + content = fs.readFileSync(workflowPath, 'utf-8'); + assert.ok(content.length > 0, 'execute-phase.md must not be empty'); + }); + + test('offer_next section checks for CONTEXT.md existence', () => { + content = content || fs.readFileSync(workflowPath, 'utf-8'); + // The workflow must check for CONTEXT.md in the next phase directory + assert.ok( + content.includes('CONTEXT.md'), + 'offer_next must reference CONTEXT.md to determine primary next step' + ); + }); + + test('offer_next presents /gsd-discuss-phase when CONTEXT.md does not exist', () => { + content = content || fs.readFileSync(workflowPath, 'utf-8'); + // Must have a conditional path where discuss-phase is the primary step + // when CONTEXT.md is missing — look for proximity of "not exist"/"missing"/ + // "does not exist" and "gsd-discuss-phase" in the offer_next step + const offerNextIdx = content.indexOf('offer_next'); + assert.ok(offerNextIdx !== -1, 'offer_next step must exist'); + + // Use 5000-char window — the step is ~60 lines of prose before the conditionals + const offerNextSection = content.slice(offerNextIdx, offerNextIdx + 5000); + assert.ok( + /CONTEXT\.md.*does not exist|CONTEXT\.md.*not.*exist|If CONTEXT\.md does/i.test(offerNextSection) || + /gsd-discuss-phase.*recommended|recommended.*gsd-discuss-phase/i.test(offerNextSection), + 'offer_next must present /gsd-discuss-phase as primary when CONTEXT.md does not exist' + ); + }); + + test('offer_next presents /gsd-plan-phase when CONTEXT.md exists', () => { + content = content || fs.readFileSync(workflowPath, 'utf-8'); + const offerNextIdx = content.indexOf('offer_next'); + assert.ok(offerNextIdx !== -1, 'offer_next step must exist'); + + const offerNextSection = content.slice(offerNextIdx, offerNextIdx + 5000); + assert.ok( + /CONTEXT\.md.*exists|exists.*CONTEXT\.md|If CONTEXT\.md/i.test(offerNextSection), + 'offer_next must present /gsd-plan-phase as primary when CONTEXT.md exists' + ); + }); + + test('offer_next section contains at least one conditional guard before listing commands', () => { + content = content || fs.readFileSync(workflowPath, 'utf-8'); + const offerNextIdx = content.indexOf('offer_next'); + assert.ok(offerNextIdx !== -1, 'offer_next step must exist'); + + const offerNextSection = content.slice(offerNextIdx, offerNextIdx + 5000); + + // The fixed version must contain at least one "If CONTEXT.md" conditional + // guard before presenting command options. The old flat list had no guard. + assert.ok( + /If CONTEXT\.md/i.test(offerNextSection), + 'offer_next must contain at least one "If CONTEXT.md" conditional guard' + ); + }); +}); + }); +} diff --git a/tests/execute-phase-step-5-5-deviation-doc.test.cjs b/tests/execute-phase-step-5-5-deviation-doc.test.cjs index 3d619d0bb..77db8a106 100644 --- a/tests/execute-phase-step-5-5-deviation-doc.test.cjs +++ b/tests/execute-phase-step-5-5-deviation-doc.test.cjs @@ -158,3 +158,141 @@ describe('execute-phase step 5.5: cross-wave-deviation cleanup documentation (#3 ); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/fix-3722-execute-phase-human-needed-checkpoint.test.cjs — consolidation epic #1969 (B4 #1973) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:fix-3722-execute-phase-human-needed-checkpoint (consolidation epic #1969 B4 #1973)", () => { +// allow-test-rule: source-text-is-the-product (see #3722) +// execute-phase.md IS the runtime contract loaded by the orchestrator. +// Asserting that the "ack-and-advance" path is absent is the only way to verify +// the state machine lie (issue #38) cannot regress at runtime. +'use strict'; + +/** + * execute-phase.md human_needed branch — issue #38 / fix #3722 + * + * The old design offered '"approved" → continue' as a shortcut that advanced + * ROADMAP.md without completing human verification. This is a state machine lie: + * the phase appears complete in the project record while HUMAN-UAT.md items + * remain unresolved. + * + * The correct design: + * - human_needed branch creates a {phase_num}-UAT.md file (not {phase_num}-HUMAN-UAT.md) + * - directs the user to /gsd:verify-work to complete verification + * - does NOT call update_roadmap directly (phase completion goes through verify-work) + * - does NOT offer "approved" → continue as a bypass + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const EXECUTE_PHASE = path.join( + __dirname, + '..', + 'gsd-core', + 'workflows', + 'execute-phase.md' +); + +describe('execute-phase.md human_needed branch — issue #38', () => { + let content; + + // Read once; all tests share the string. + test('workflow file is readable', () => { + content = fs.readFileSync(EXECUTE_PHASE, 'utf-8'); + assert.ok(content.length > 0, 'execute-phase.md must be non-empty'); + }); + + test('human_needed section exists', () => { + if (!content) content = fs.readFileSync(EXECUTE_PHASE, 'utf-8'); + assert.ok( + content.includes('human_needed'), + 'execute-phase.md must contain a human_needed branch' + ); + }); + + test('"approved" → continue bypass is absent from human_needed branch', () => { + if (!content) content = fs.readFileSync(EXECUTE_PHASE, 'utf-8'); + // The old prompt offered '"approved" → continue' as a shortcut that advanced + // ROADMAP.md without completing verification. That path must not exist. + assert.ok( + !content.includes('"approved" → continue'), + 'human_needed branch must not offer "approved" → continue: it marks the phase complete without verification (issue #38)' + ); + }); + + test('human_needed branch does NOT call update_roadmap directly', () => { + if (!content) content = fs.readFileSync(EXECUTE_PHASE, 'utf-8'); + // Locate the human_needed section and check that update_roadmap does not + // appear before the gaps_found section (i.e. it is not reachable from human_needed). + const humanNeededIdx = content.indexOf('**If human_needed:**'); + const gapsFoundIdx = content.indexOf('**If gaps_found:**'); + const updateRoadmapIdx = content.indexOf('update_roadmap', humanNeededIdx); + + assert.ok(humanNeededIdx !== -1, '**If human_needed:** section must exist'); + assert.ok(gapsFoundIdx !== -1, '**If gaps_found:** section must exist'); + assert.ok( + humanNeededIdx < gapsFoundIdx, + 'human_needed section must appear before gaps_found section' + ); + + // update_roadmap must not appear between human_needed and gaps_found sections + const updateRoadmapBetween = + updateRoadmapIdx !== -1 && + updateRoadmapIdx > humanNeededIdx && + updateRoadmapIdx < gapsFoundIdx; + + assert.ok( + !updateRoadmapBetween, + 'update_roadmap must not be reachable directly from the human_needed branch — phase completion must go through verify-work (issue #38)' + ); + }); + + test('human_needed branch directs user to /gsd:verify-work', () => { + if (!content) content = fs.readFileSync(EXECUTE_PHASE, 'utf-8'); + const humanNeededIdx = content.indexOf('**If human_needed:**'); + const gapsFoundIdx = content.indexOf('**If gaps_found:**'); + assert.ok(humanNeededIdx !== -1, '**If human_needed:** section must exist'); + + const humanNeededSection = content.slice( + humanNeededIdx, + gapsFoundIdx !== -1 ? gapsFoundIdx : undefined + ); + assert.ok( + humanNeededSection.includes('verify-work'), + 'human_needed branch must direct the user to /gsd:verify-work to complete verification' + ); + }); + + test('human_needed branch creates {phase_num}-UAT.md (not HUMAN-UAT.md)', () => { + if (!content) content = fs.readFileSync(EXECUTE_PHASE, 'utf-8'); + const humanNeededIdx = content.indexOf('**If human_needed:**'); + const gapsFoundIdx = content.indexOf('**If gaps_found:**'); + assert.ok(humanNeededIdx !== -1, '**If human_needed:** section must exist'); + + const humanNeededSection = content.slice( + humanNeededIdx, + gapsFoundIdx !== -1 ? gapsFoundIdx : undefined + ); + + // The file should be named {phase_num}-UAT.md so verify-work's glob picks it up + assert.ok( + humanNeededSection.includes('-UAT.md'), + 'human_needed branch must create a {phase_num}-UAT.md file for verify-work to resume' + ); + + // HUMAN-UAT.md causes a naming mismatch with verify-work's create_uat_file step + assert.ok( + !humanNeededSection.includes('HUMAN-UAT.md'), + 'human_needed branch must NOT create HUMAN-UAT.md — use {phase_num}-UAT.md to align with verify-work\'s resume path (issue #38 edge case 3)' + ); + }); +}); + }); +} diff --git a/tests/execute-phase-wave.test.cjs b/tests/execute-phase-wave.test.cjs index d4ae1cbba..7b9a04536 100644 --- a/tests/execute-phase-wave.test.cjs +++ b/tests/execute-phase-wave.test.cjs @@ -292,3 +292,450 @@ describe('use_worktrees config: cross-workflow structural coverage', () => { } }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2410-stream-checkpoint-heartbeats.test.cjs — consolidation epic #1969 (B4 #1973) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2410-stream-checkpoint-heartbeats (consolidation epic #1969 B4 #1973)", () => { +// allow-test-rule: source-text-is-the-product (see #2410) +// Workflow .md / agent .md / command .md / reference .md files — their text +// IS what the runtime loads. Testing text content tests the deployed contract. +// Per CONTRIBUTING.md exception matrix. + +/** + * Bug #2410 — /gsd:manager background execute-phase Task fails with + * "Stream idle timeout" on multi-plan phases. + * + * Fix: execute-phase.md instructs the orchestrator to emit `[checkpoint]` + * heartbeat lines at every wave boundary AND every plan boundary so the + * Claude API SSE stream never idles long enough to trigger the platform + * timeout. This test validates the workflow contract that backs that fix. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const WORKFLOW_PATH = path.join( + __dirname, + '..', + 'gsd-core', + 'workflows', + 'execute-phase.md' +); +const COMMANDS_DOC_PATH = path.join(__dirname, '..', 'docs', 'COMMANDS.md'); + +describe('bug #2410: execute-phase emits checkpoint heartbeats', () => { + const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf-8'); + + test('workflow references the stream idle timeout symptom by name', () => { + assert.ok( + /Stream idle timeout/.test(workflow), + 'workflow should name the API error it is preventing' + ); + assert.ok( + workflow.includes('#2410'), + 'workflow should cite the tracking issue for future maintainers' + ); + }); + + test('workflow defines a [checkpoint] heartbeat line format', () => { + assert.ok( + workflow.includes('[checkpoint]'), + 'workflow should document the [checkpoint] marker prefix' + ); + }); + + test('workflow emits a wave-start heartbeat (A: wave-boundary checkpoint)', () => { + assert.ok( + /\[checkpoint\][^\r\n]*wave \{N\}\/\{M\} starting/.test(workflow), + 'workflow should emit a wave-start [checkpoint] marker before spawning agents' + ); + }); + + test('workflow emits a wave-complete heartbeat (A: wave-boundary checkpoint)', () => { + assert.ok( + /\[checkpoint\][^\r\n]*wave \{N\}\/\{M\} complete/.test(workflow), + 'workflow should emit a wave-complete [checkpoint] marker after spot-checks' + ); + }); + + test('workflow emits a plan-start heartbeat (B: plan-boundary checkpoint)', () => { + assert.ok( + /\[checkpoint\][^\r\n]*plan \{plan_id\} starting/.test(workflow), + 'workflow should emit a plan-start [checkpoint] marker before each Task() dispatch' + ); + }); + + test('workflow emits a plan-complete heartbeat (B: plan-boundary checkpoint)', () => { + assert.ok( + /\[checkpoint\][^\r\n]*plan \{plan_id\} complete/.test(workflow), + 'workflow should emit a plan-complete [checkpoint] marker after executor returns' + ); + }); + + test('workflow handles plan failure and checkpoint-gate heartbeats too', () => { + assert.ok( + /\[checkpoint\][^\r\n]*plan \{plan_id\} failed/.test(workflow), + 'workflow should emit a plan-failed [checkpoint] marker on executor error' + ); + assert.ok( + /\[checkpoint\][^\r\n]*plan \{plan_id\} checkpoint/.test(workflow), + 'workflow should emit a heartbeat when a plan returns a human-gate checkpoint' + ); + }); + + test('heartbeats include a monotonic plans-done counter', () => { + // The {P}/{Q} counter lets grep-based recovery tools reconstruct progress + // from a truncated transcript if the agent dies mid-phase. + assert.ok( + /\{P\}\/\{Q\} plans done/.test(workflow), + 'heartbeats should include a {P}/{Q} phase-wide completed-plan counter' + ); + }); + + test('wave-start heartbeat precedes the "Describe what\'s being built" text', () => { + const describeIdx = workflow.indexOf("Describe what's being built"); + const heartbeatIdx = workflow.indexOf( + '[checkpoint] phase {PHASE_NUMBER} wave {N}/{M} starting' + ); + assert.ok(describeIdx !== -1, 'workflow should still have the describe step'); + assert.ok(heartbeatIdx !== -1, 'wave-start heartbeat template should be present'); + // The instruction to emit the heartbeat appears in step 2, which is the + // step titled "Describe what's being built". The actual sentinel text we + // look for is the inline literal template — it must be emitted BEFORE any + // tool calls in that step. + const step2 = workflow.slice( + describeIdx, + workflow.indexOf('3. **Spawn executor agents', describeIdx) + ); + assert.ok( + step2.includes('[checkpoint]'), + 'step 2 should instruct the orchestrator to emit a [checkpoint] heartbeat' + ); + assert.ok( + /before any further reasoning or spawning/i.test(step2) || + /before any tool call/i.test(step2) || + /no tool call/i.test(step2), + 'step 2 should make clear the heartbeat is an assistant-text line, not a tool call' + ); + }); + + test('plan-start heartbeat is inside the spawn step', () => { + const spawnIdx = workflow.indexOf('3. **Spawn executor agents'); + const waitIdx = workflow.indexOf('4. **Wait for all agents', spawnIdx); + assert.ok(spawnIdx !== -1 && waitIdx !== -1, 'spawn and wait steps must exist'); + const step3 = workflow.slice(spawnIdx, waitIdx); + assert.ok( + /\[checkpoint\][^\r\n]*plan \{plan_id\} starting/.test(step3), + 'plan-start heartbeat should be emitted inside step 3 (spawn executor agents)' + ); + }); + + test('plan-complete and wave-complete heartbeats are inside the wait/report steps', () => { + const waitIdx = workflow.indexOf('4. **Wait for all agents'); + const hookIdx = workflow.indexOf('5. **Post-wave hook validation', waitIdx); + assert.ok(waitIdx !== -1 && hookIdx !== -1, 'wait + hook steps must exist'); + const step4 = workflow.slice(waitIdx, hookIdx); + assert.ok( + /\[checkpoint\][^\r\n]*plan \{plan_id\} complete/.test(step4), + 'plan-complete heartbeat should be emitted in step 4 (wait for agents)' + ); + + const reportIdx = workflow.indexOf('6. **Report completion'); + const failureIdx = workflow.indexOf('7. **Handle failures', reportIdx); + assert.ok(reportIdx !== -1 && failureIdx !== -1, 'report + failure steps must exist'); + const step6 = workflow.slice(reportIdx, failureIdx); + assert.ok( + /\[checkpoint\][^\r\n]*wave \{N\}\/\{M\} complete/.test(step6), + 'wave-complete heartbeat should be emitted in step 6 (report completion)' + ); + }); +}); + +describe('bug #2410: checkpoint heartbeat format is user-documented', () => { + const commandsDoc = fs.readFileSync(COMMANDS_DOC_PATH, 'utf-8'); + + test('COMMANDS.md documents the [checkpoint] format under /gsd-manager', () => { + const managerIdx = commandsDoc.indexOf('### `/gsd-manager`'); + assert.ok(managerIdx !== -1, '/gsd-manager section should exist'); + const section = commandsDoc.slice(managerIdx, managerIdx + 4000); + assert.ok( + /\[checkpoint\]/.test(section), + 'COMMANDS.md /gsd-manager section should document [checkpoint] heartbeat markers' + ); + assert.ok( + /Stream idle timeout/i.test(section), + 'COMMANDS.md should explain what the heartbeats prevent' + ); + assert.ok( + /#2410/.test(section), + 'COMMANDS.md should reference the tracking issue' + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/fix-1369-wave-stale-base.test.cjs — consolidation epic #1969 (B4 #1973) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:fix-1369-wave-stale-base (consolidation epic #1969 B4 #1973)", () => { +// allow-test-rule: source-text-is-the-product #1369 +// Workflow .md files are the installed AI instructions — their text IS what the runtime +// loads. Testing text content tests the deployed contract. Per CONTRIBUTING.md exception matrix. + +/** + * Regression tests for bug #1369: execute-phase worktree agents fork from stale base after + * a wave merge advances orchestrator HEAD past origin/HEAD. + * + * Steps 0.5 and 7b+7c are extracted to reference files to satisfy the ADR-857 size cap. + * execute-phase.md contains @-reference pointers; the reference files hold the content. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const WORKFLOW_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md'); +const WAVE_GUARD_PATH = path.join(__dirname, '..', 'gsd-core', 'references', 'execute-phase-wave-guard.md'); +const BETWEEN_WAVE_PATH = path.join(__dirname, '..', 'gsd-core', 'references', 'execute-phase-between-wave-reset.md'); + +describe('execute-phase: inter-wave worktree base re-check (#1369)', () => { + test('workflow file exists', () => { + assert.ok(fs.existsSync(WORKFLOW_PATH), 'workflows/execute-phase.md should exist'); + }); + + test('wave-guard reference file exists', () => { + assert.ok(fs.existsSync(WAVE_GUARD_PATH), 'references/execute-phase-wave-guard.md should exist'); + }); + + test('workflow contains @-reference pointer to wave-guard (step 0.5 injected at runtime)', () => { + const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8'); + assert.ok( + content.includes('execute-phase-wave-guard.md'), + 'execute-phase.md must have an @-reference to execute-phase-wave-guard.md' + ); + }); + + test('workflow contains step 0.5 inter-wave base re-check section', () => { + const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8'); + assert.ok( + content.includes('0.5.') && content.includes('Inter-wave worktree base re-check'), + 'execute-phase-wave-guard.md must have step 0.5 "Inter-wave worktree base re-check"' + ); + }); + + test('step 0.5 references #1369', () => { + const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8'); + assert.ok(content.includes('#1369'), 'step 0.5 must reference #1369 for traceability'); + }); + + test('step 0.5 runs worktree.base-check inside the For-each-wave loop', () => { + const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf-8'); + const forEachIdx = workflow.indexOf('**For each wave:**'); + const refIdx = workflow.indexOf('execute-phase-wave-guard.md'); + assert.ok(forEachIdx !== -1, '"For each wave:" section must exist in execute-phase.md'); + assert.ok(refIdx !== -1, '@-reference to wave-guard must exist in execute-phase.md'); + assert.ok(refIdx > forEachIdx, 'wave-guard @-reference must appear AFTER "For each wave:" so step 0.5 runs per-wave'); + }); + + test('step 0.5 runs worktree.base-check command', () => { + const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8'); + assert.ok(content.includes('worktree.base-check'), 'step 0.5 must invoke worktree.base-check'); + }); + + test('step 0.5 sets USE_WORKTREES=false when shouldDegrade is true', () => { + const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8'); + assert.ok(content.includes('USE_WORKTREES=false'), 'step 0.5 must override USE_WORKTREES=false when base divergence is detected'); + }); + + test('step 0.5 appears before step 1 (intra-wave overlap check)', () => { + const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf-8'); + const forEachIdx = workflow.indexOf('**For each wave:**'); + const refIdx = workflow.indexOf('execute-phase-wave-guard.md'); + const step1Idx = workflow.indexOf('1. **Intra-wave', forEachIdx); + assert.ok(refIdx !== -1, 'wave-guard @-reference must exist'); + assert.ok(step1Idx !== -1, 'step 1 (intra-wave overlap check) must exist'); + assert.ok(refIdx < step1Idx, 'wave-guard @-reference must appear before step 1'); + }); + + test('step 0.5 guards on RUNTIME=claude (worktree isolation is Claude Code-specific)', () => { + const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8'); + assert.ok( + content.includes('RUNTIME') && (content.includes('"claude"') || content.includes("'claude'")), + 'step 0.5 must guard on RUNTIME=claude' + ); + }); + + test('step 0.5 explains root cause: wave merges advance HEAD past origin/HEAD', () => { + const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8'); + assert.ok(content.includes('origin/HEAD'), 'step 0.5 must name origin/HEAD as the stale fork base'); + }); + + test('step 0.5 cross-references #683 for worktree.baseRef configuration', () => { + const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8'); + assert.ok(content.includes('#683'), 'step 0.5 must cross-reference #683'); + }); + + test('step 0.5 mentions worktree.baseRef:"head" as permanent fix', () => { + const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8'); + assert.ok( + content.includes('worktree.baseRef') && content.includes('head'), + 'step 0.5 must mention worktree.baseRef:"head"' + ); + }); +}); + +describe('execute-phase: between-wave manifest reset (#1369, #3384)', () => { + test('between-wave reference file exists', () => { + assert.ok(fs.existsSync(BETWEEN_WAVE_PATH), 'references/execute-phase-between-wave-reset.md should exist'); + }); + + test('workflow contains @-reference pointer to between-wave-reset', () => { + const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8'); + assert.ok( + content.includes('execute-phase-between-wave-reset.md'), + 'execute-phase.md must have an @-reference to execute-phase-between-wave-reset.md' + ); + }); + + test('step 7c exists with between-wave manifest reset (#1369)', () => { + const content = fs.readFileSync(BETWEEN_WAVE_PATH, 'utf-8'); + assert.ok( + content.includes('7c.') && content.includes('Between-wave manifest reset'), + 'execute-phase-between-wave-reset.md must have step 7c "Between-wave manifest reset"' + ); + }); + + test('step 7c unsets WAVE_WORKTREE_MANIFEST between waves', () => { + const content = fs.readFileSync(BETWEEN_WAVE_PATH, 'utf-8'); + assert.ok(content.includes('unset WAVE_WORKTREE_MANIFEST'), 'step 7c must unset WAVE_WORKTREE_MANIFEST'); + }); + + test('step 7c references #1369 and #3384 for traceability', () => { + const content = fs.readFileSync(BETWEEN_WAVE_PATH, 'utf-8'); + assert.ok(content.includes('#1369'), 'step 7c must reference #1369'); + assert.ok(content.includes('#3384'), 'step 7c must reference #3384'); + }); + + test('step 7c calls worktree.set-baseref to re-assert head config', () => { + const content = fs.readFileSync(BETWEEN_WAVE_PATH, 'utf-8'); + assert.ok(content.includes('worktree.set-baseref'), 'step 7c must call worktree.set-baseref'); + }); + + test('step 7c appears after step 7b and before step 8 in the wave loop', () => { + const ref = fs.readFileSync(BETWEEN_WAVE_PATH, 'utf-8'); + const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf-8'); + const idx7b = ref.indexOf('7b.'); + const idx7c = ref.indexOf('7c.'); + const refPtr = workflow.indexOf('execute-phase-between-wave-reset.md'); + const idx8 = workflow.indexOf('8. **Execute checkpoint', refPtr); + assert.ok(idx7b !== -1, 'step 7b must exist in between-wave reference file'); + assert.ok(idx7c !== -1, 'step 7c must exist in between-wave reference file'); + assert.ok(idx8 !== -1, 'step 8 must exist in execute-phase.md after the between-wave @-reference'); + assert.ok(idx7b < idx7c, 'step 7c must appear after step 7b'); + assert.ok(refPtr < idx8, 'between-wave @-reference must appear before step 8'); + }); + + test('step 7c guards on RUNTIME=claude for worktree-specific operations', () => { + const content = fs.readFileSync(BETWEEN_WAVE_PATH, 'utf-8'); + assert.ok( + content.includes('RUNTIME') && (content.includes('"claude"') || content.includes("'claude'")), + 'step 7c must guard on RUNTIME=claude' + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3096-ai-integration-phase-parallel-race.test.cjs — consolidation epic #1969 (B4 #1973) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3096-ai-integration-phase-parallel-race (consolidation epic #1969 B4 #1973)", () => { +'use strict'; +// allow-test-rule: reads product workflow markdown (ai-integration-phase.md) to verify structural ordering contract — not a source-grep test (see #3096) + +// Regression guard for bug #3096. +// +// ai-integration-phase.md listed Steps 7+8 (gsd-ai-researcher + +// gsd-domain-researcher) without an explicit sequential ordering constraint. +// An orchestrator optimizing for speed could reasonably parallelize them +// since the sections appeared disjoint. When parallelized, gsd-domain-researcher's +// Write call at finalization replaced the whole AI-SPEC.md file with its +// in-memory copy (pre-researcher state), silently overwriting Sections 3/4. +// +// Confirmed at 40% incidence rate on a real run (2 of 5 worktree agents hit it). +// Recovery cost: one extra ai-researcher dispatch (~18 min wall). +// +// Fix: +// 1. Explicit "MUST run sequentially" note on Steps 7 and 8 +// 2. Edit-only tool discipline injected into both agent prompts + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const ROOT = path.join(__dirname, '..'); +const src = fs.readFileSync( + path.join(ROOT, 'gsd-core', 'workflows', 'ai-integration-phase.md'), + 'utf8', +); + +describe('bug #3096: ai-integration-phase sequential ordering and Edit-only discipline', () => { + test('Step 7 documents sequential ordering requirement', () => { + assert.ok( + src.includes('sequentially') || src.includes('sequential'), + 'Steps 7+8 ordering note is missing — parallel dispatch race can recur', + ); + }); + + test('Step 7 gsd-ai-researcher prompt includes Edit-only tool discipline', () => { + // The discipline block must appear before for gsd-ai-researcher + const step7Idx = src.indexOf('## 7. Spawn gsd-ai-researcher'); + const step8Idx = src.indexOf('## 8. Spawn gsd-domain-researcher'); + assert.ok(step7Idx !== -1, 'Step 7 not found'); + assert.ok(step8Idx !== -1, 'Step 8 not found'); + const step7Block = src.slice(step7Idx, step8Idx); + assert.ok( + step7Block.includes('Edit tool') && step7Block.includes('NEVER use Write'), + 'Step 7 agent prompt missing Edit-only tool discipline', + ); + }); + + test('Step 8 gsd-domain-researcher prompt includes Edit-only tool discipline', () => { + const step8Idx = src.indexOf('## 8. Spawn gsd-domain-researcher'); + const step9Idx = src.indexOf('## 9. Spawn gsd-eval-planner'); + assert.ok(step8Idx !== -1, 'Step 8 not found'); + assert.ok(step9Idx !== -1, 'Step 9 not found'); + const step8Block = src.slice(step8Idx, step9Idx); + assert.ok( + step8Block.includes('Edit tool') && step8Block.includes('NEVER use Write'), + 'Step 8 agent prompt missing Edit-only tool discipline', + ); + }); + + test('Step 8 references the wait instruction', () => { + const step8Idx = src.indexOf('## 8. Spawn gsd-domain-researcher'); + const step9Idx = src.indexOf('## 9. Spawn gsd-eval-planner'); + const step8Block = src.slice(step8Idx, step9Idx); + assert.ok( + step8Block.includes('Wait') || step8Block.includes('wait') || step8Block.includes('complete'), + 'Step 8 does not instruct orchestrator to wait for Step 7', + ); + }); +}); + }); +} diff --git a/tests/feat-22-surfacing-docs.test.cjs b/tests/feat-22-surfacing-docs.test.cjs deleted file mode 100644 index dbd53ad89..000000000 --- a/tests/feat-22-surfacing-docs.test.cjs +++ /dev/null @@ -1,198 +0,0 @@ -// allow-test-rule: docs-parity -// Verifies that issue #22 drift-guard surfacing changes are present: -// - new-project workflow mentions plan_review.source_grounding -// - CONFIGURATION.md documents both new config keys -// - COMMANDS.md mentions gsd-tools intel api-surface - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const ROOT = path.join(__dirname, '..'); - -const NEW_PROJECT_PATH = path.join(ROOT, 'gsd-core', 'workflows', 'new-project.md'); -const SETTINGS_PATH = path.join(ROOT, 'gsd-core', 'workflows', 'settings.md'); -const CONFIGURATION_PATH = path.join(ROOT, 'docs', 'CONFIGURATION.md'); -const COMMANDS_PATH = path.join(ROOT, 'docs', 'COMMANDS.md'); -const USER_GUIDE_PATH = path.join(ROOT, 'docs', 'USER-GUIDE.md'); -const ARCHITECTURE_PATH = path.join(ROOT, 'docs', 'ARCHITECTURE.md'); - -describe('feat-22-surfacing-docs', () => { - // ── A1: new-project workflow ───────────────────────────────────────────── - - test('new-project workflow mentions source_grounding', () => { - const content = fs.readFileSync(NEW_PROJECT_PATH, 'utf-8'); - assert.ok( - content.includes('source_grounding'), - 'new-project.md must mention source_grounding' - ); - }); - - test('new-project workflow has Drift Guard question', () => { - const content = fs.readFileSync(NEW_PROJECT_PATH, 'utf-8'); - assert.ok( - content.includes('Drift Guard'), - 'new-project.md must include a "Drift Guard" question header' - ); - }); - - test('new-project workflow wires source_grounding into config-new-project call', () => { - const content = fs.readFileSync(NEW_PROJECT_PATH, 'utf-8'); - assert.ok( - content.includes('"plan_review":{"source_grounding":'), - 'new-project.md config-new-project call must include plan_review.source_grounding' - ); - }); - - test('new-project workflow has Drift Guard default-yes option', () => { - const content = fs.readFileSync(NEW_PROJECT_PATH, 'utf-8'); - // Both question blocks (auto and interactive) should have the yes option - const count = (content.match(/Yes \(Recommended\).*catches hallucinated names/g) || []).length; - assert.ok( - count >= 1, - 'new-project.md must have at least one Drift Guard "Yes (Recommended)" option' - ); - }); - - // ── A2: settings workflow ──────────────────────────────────────────────── - - test('settings workflow mentions source_grounding in read_current step', () => { - const content = fs.readFileSync(SETTINGS_PATH, 'utf-8'); - assert.ok( - content.includes('plan_review.source_grounding'), - 'settings.md must mention plan_review.source_grounding in the read_current step' - ); - }); - - test('settings workflow has Drift Guard AskUserQuestion toggle', () => { - const content = fs.readFileSync(SETTINGS_PATH, 'utf-8'); - assert.ok( - content.includes('Drift Guard'), - 'settings.md must include a "Drift Guard" question header' - ); - }); - - test('settings workflow update_config includes plan_review.source_grounding', () => { - const content = fs.readFileSync(SETTINGS_PATH, 'utf-8'); - assert.ok( - content.includes('"source_grounding": true/false'), - 'settings.md update_config block must include source_grounding: true/false' - ); - }); - - test('settings workflow mentions source_grounding_authority', () => { - const content = fs.readFileSync(SETTINGS_PATH, 'utf-8'); - assert.ok( - content.includes('source_grounding_authority'), - 'settings.md must mention source_grounding_authority' - ); - }); - - test('settings confirm table includes Plan Drift Guard row', () => { - const content = fs.readFileSync(SETTINGS_PATH, 'utf-8'); - assert.ok( - content.includes('Plan Drift Guard'), - 'settings.md confirm table must include a "Plan Drift Guard" row' - ); - }); - - // ── B1: CONFIGURATION.md ───────────────────────────────────────────────── - - test('CONFIGURATION.md documents plan_review.source_grounding', () => { - const content = fs.readFileSync(CONFIGURATION_PATH, 'utf-8'); - assert.ok( - content.includes('`plan_review.source_grounding`'), - 'CONFIGURATION.md must document plan_review.source_grounding' - ); - }); - - test('CONFIGURATION.md documents plan_review.source_grounding_authority', () => { - const content = fs.readFileSync(CONFIGURATION_PATH, 'utf-8'); - assert.ok( - content.includes('`plan_review.source_grounding_authority`'), - 'CONFIGURATION.md must document plan_review.source_grounding_authority' - ); - }); - - test('CONFIGURATION.md documents grep as default authority', () => { - const content = fs.readFileSync(CONFIGURATION_PATH, 'utf-8'); - assert.ok( - content.includes('`grep`') && content.includes('source_grounding_authority'), - 'CONFIGURATION.md must document grep as the default source_grounding_authority' - ); - }); - - test('CONFIGURATION.md lists all five authority enum values', () => { - const content = fs.readFileSync(CONFIGURATION_PATH, 'utf-8'); - const authorities = ['grep', 'intel', 'treesitter', 'lsp', 'scip']; - const missing = authorities.filter(a => !content.includes(a)); - assert.deepStrictEqual( - missing, - [], - `CONFIGURATION.md must list all authority values; missing: ${missing.join(', ')}` - ); - }); - - // ── B2: COMMANDS.md ─────────────────────────────────────────────────────── - - test('COMMANDS.md mentions intel api-surface', () => { - const content = fs.readFileSync(COMMANDS_PATH, 'utf-8'); - assert.ok( - content.includes('intel api-surface'), - 'COMMANDS.md must document the gsd-tools intel api-surface command' - ); - }); - - test('COMMANDS.md documents api-surface gating on intel.enabled', () => { - const content = fs.readFileSync(COMMANDS_PATH, 'utf-8'); - assert.ok( - content.includes('intel.enabled'), - 'COMMANDS.md intel api-surface section must mention the intel.enabled gate' - ); - }); - - test('COMMANDS.md mentions API-SURFACE.md output', () => { - const content = fs.readFileSync(COMMANDS_PATH, 'utf-8'); - assert.ok( - content.includes('API-SURFACE.md'), - 'COMMANDS.md must mention the API-SURFACE.md output file' - ); - }); - - // ── B3: USER-GUIDE.md ───────────────────────────────────────────────────── - - test('USER-GUIDE.md has Plan Drift Guard subsection', () => { - const content = fs.readFileSync(USER_GUIDE_PATH, 'utf-8'); - assert.ok( - content.includes('### Plan Drift Guard'), - 'USER-GUIDE.md must have a "### Plan Drift Guard" subsection' - ); - }); - - test('USER-GUIDE.md mentions needs-acknowledgement behavior', () => { - const content = fs.readFileSync(USER_GUIDE_PATH, 'utf-8'); - assert.ok( - content.includes('needs-acknowledgement'), - 'USER-GUIDE.md drift guard section must describe needs-acknowledgement behavior' - ); - }); - - test('USER-GUIDE.md explains drift guard works without intel', () => { - const content = fs.readFileSync(USER_GUIDE_PATH, 'utf-8'); - assert.ok( - content.includes('without intel') || content.includes('Works without intel'), - 'USER-GUIDE.md must explain that the drift guard works without intel' - ); - }); - - // ── B4: ARCHITECTURE.md ─────────────────────────────────────────────────── - - test('ARCHITECTURE.md links to ADR 22', () => { - const content = fs.readFileSync(ARCHITECTURE_PATH, 'utf-8'); - assert.ok( - content.includes('adr/22-plan-drift-guard.md') || content.includes('ADR 22'), - 'ARCHITECTURE.md must link to ADR 22 (adr/22-plan-drift-guard.md)' - ); - }); -}); diff --git a/tests/fix-1369-wave-stale-base.test.cjs b/tests/fix-1369-wave-stale-base.test.cjs deleted file mode 100644 index 0d775f3ea..000000000 --- a/tests/fix-1369-wave-stale-base.test.cjs +++ /dev/null @@ -1,166 +0,0 @@ -// allow-test-rule: source-text-is-the-product #1369 -// Workflow .md files are the installed AI instructions — their text IS what the runtime -// loads. Testing text content tests the deployed contract. Per CONTRIBUTING.md exception matrix. - -/** - * Regression tests for bug #1369: execute-phase worktree agents fork from stale base after - * a wave merge advances orchestrator HEAD past origin/HEAD. - * - * Steps 0.5 and 7b+7c are extracted to reference files to satisfy the ADR-857 size cap. - * execute-phase.md contains @-reference pointers; the reference files hold the content. - */ - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const WORKFLOW_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md'); -const WAVE_GUARD_PATH = path.join(__dirname, '..', 'gsd-core', 'references', 'execute-phase-wave-guard.md'); -const BETWEEN_WAVE_PATH = path.join(__dirname, '..', 'gsd-core', 'references', 'execute-phase-between-wave-reset.md'); - -describe('execute-phase: inter-wave worktree base re-check (#1369)', () => { - test('workflow file exists', () => { - assert.ok(fs.existsSync(WORKFLOW_PATH), 'workflows/execute-phase.md should exist'); - }); - - test('wave-guard reference file exists', () => { - assert.ok(fs.existsSync(WAVE_GUARD_PATH), 'references/execute-phase-wave-guard.md should exist'); - }); - - test('workflow contains @-reference pointer to wave-guard (step 0.5 injected at runtime)', () => { - const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8'); - assert.ok( - content.includes('execute-phase-wave-guard.md'), - 'execute-phase.md must have an @-reference to execute-phase-wave-guard.md' - ); - }); - - test('workflow contains step 0.5 inter-wave base re-check section', () => { - const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8'); - assert.ok( - content.includes('0.5.') && content.includes('Inter-wave worktree base re-check'), - 'execute-phase-wave-guard.md must have step 0.5 "Inter-wave worktree base re-check"' - ); - }); - - test('step 0.5 references #1369', () => { - const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8'); - assert.ok(content.includes('#1369'), 'step 0.5 must reference #1369 for traceability'); - }); - - test('step 0.5 runs worktree.base-check inside the For-each-wave loop', () => { - const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf-8'); - const forEachIdx = workflow.indexOf('**For each wave:**'); - const refIdx = workflow.indexOf('execute-phase-wave-guard.md'); - assert.ok(forEachIdx !== -1, '"For each wave:" section must exist in execute-phase.md'); - assert.ok(refIdx !== -1, '@-reference to wave-guard must exist in execute-phase.md'); - assert.ok(refIdx > forEachIdx, 'wave-guard @-reference must appear AFTER "For each wave:" so step 0.5 runs per-wave'); - }); - - test('step 0.5 runs worktree.base-check command', () => { - const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8'); - assert.ok(content.includes('worktree.base-check'), 'step 0.5 must invoke worktree.base-check'); - }); - - test('step 0.5 sets USE_WORKTREES=false when shouldDegrade is true', () => { - const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8'); - assert.ok(content.includes('USE_WORKTREES=false'), 'step 0.5 must override USE_WORKTREES=false when base divergence is detected'); - }); - - test('step 0.5 appears before step 1 (intra-wave overlap check)', () => { - const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf-8'); - const forEachIdx = workflow.indexOf('**For each wave:**'); - const refIdx = workflow.indexOf('execute-phase-wave-guard.md'); - const step1Idx = workflow.indexOf('1. **Intra-wave', forEachIdx); - assert.ok(refIdx !== -1, 'wave-guard @-reference must exist'); - assert.ok(step1Idx !== -1, 'step 1 (intra-wave overlap check) must exist'); - assert.ok(refIdx < step1Idx, 'wave-guard @-reference must appear before step 1'); - }); - - test('step 0.5 guards on RUNTIME=claude (worktree isolation is Claude Code-specific)', () => { - const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8'); - assert.ok( - content.includes('RUNTIME') && (content.includes('"claude"') || content.includes("'claude'")), - 'step 0.5 must guard on RUNTIME=claude' - ); - }); - - test('step 0.5 explains root cause: wave merges advance HEAD past origin/HEAD', () => { - const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8'); - assert.ok(content.includes('origin/HEAD'), 'step 0.5 must name origin/HEAD as the stale fork base'); - }); - - test('step 0.5 cross-references #683 for worktree.baseRef configuration', () => { - const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8'); - assert.ok(content.includes('#683'), 'step 0.5 must cross-reference #683'); - }); - - test('step 0.5 mentions worktree.baseRef:"head" as permanent fix', () => { - const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8'); - assert.ok( - content.includes('worktree.baseRef') && content.includes('head'), - 'step 0.5 must mention worktree.baseRef:"head"' - ); - }); -}); - -describe('execute-phase: between-wave manifest reset (#1369, #3384)', () => { - test('between-wave reference file exists', () => { - assert.ok(fs.existsSync(BETWEEN_WAVE_PATH), 'references/execute-phase-between-wave-reset.md should exist'); - }); - - test('workflow contains @-reference pointer to between-wave-reset', () => { - const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8'); - assert.ok( - content.includes('execute-phase-between-wave-reset.md'), - 'execute-phase.md must have an @-reference to execute-phase-between-wave-reset.md' - ); - }); - - test('step 7c exists with between-wave manifest reset (#1369)', () => { - const content = fs.readFileSync(BETWEEN_WAVE_PATH, 'utf-8'); - assert.ok( - content.includes('7c.') && content.includes('Between-wave manifest reset'), - 'execute-phase-between-wave-reset.md must have step 7c "Between-wave manifest reset"' - ); - }); - - test('step 7c unsets WAVE_WORKTREE_MANIFEST between waves', () => { - const content = fs.readFileSync(BETWEEN_WAVE_PATH, 'utf-8'); - assert.ok(content.includes('unset WAVE_WORKTREE_MANIFEST'), 'step 7c must unset WAVE_WORKTREE_MANIFEST'); - }); - - test('step 7c references #1369 and #3384 for traceability', () => { - const content = fs.readFileSync(BETWEEN_WAVE_PATH, 'utf-8'); - assert.ok(content.includes('#1369'), 'step 7c must reference #1369'); - assert.ok(content.includes('#3384'), 'step 7c must reference #3384'); - }); - - test('step 7c calls worktree.set-baseref to re-assert head config', () => { - const content = fs.readFileSync(BETWEEN_WAVE_PATH, 'utf-8'); - assert.ok(content.includes('worktree.set-baseref'), 'step 7c must call worktree.set-baseref'); - }); - - test('step 7c appears after step 7b and before step 8 in the wave loop', () => { - const ref = fs.readFileSync(BETWEEN_WAVE_PATH, 'utf-8'); - const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf-8'); - const idx7b = ref.indexOf('7b.'); - const idx7c = ref.indexOf('7c.'); - const refPtr = workflow.indexOf('execute-phase-between-wave-reset.md'); - const idx8 = workflow.indexOf('8. **Execute checkpoint', refPtr); - assert.ok(idx7b !== -1, 'step 7b must exist in between-wave reference file'); - assert.ok(idx7c !== -1, 'step 7c must exist in between-wave reference file'); - assert.ok(idx8 !== -1, 'step 8 must exist in execute-phase.md after the between-wave @-reference'); - assert.ok(idx7b < idx7c, 'step 7c must appear after step 7b'); - assert.ok(refPtr < idx8, 'between-wave @-reference must appear before step 8'); - }); - - test('step 7c guards on RUNTIME=claude for worktree-specific operations', () => { - const content = fs.readFileSync(BETWEEN_WAVE_PATH, 'utf-8'); - assert.ok( - content.includes('RUNTIME') && (content.includes('"claude"') || content.includes("'claude'")), - 'step 7c must guard on RUNTIME=claude' - ); - }); -}); diff --git a/tests/fix-1520-workflow-mktemp-suffix-final.test.cjs b/tests/fix-1520-workflow-mktemp-suffix-final.test.cjs deleted file mode 100644 index 1e904a5f4..000000000 --- a/tests/fix-1520-workflow-mktemp-suffix-final.test.cjs +++ /dev/null @@ -1,78 +0,0 @@ -// allow-test-rule: source-text-is-the-product (#1520) -// Workflow .md text IS what the runtime loads and the agent executes, so -// asserting on its shell invocations tests the deployed contract directly. -// -// Repo-wide regression guard for #1520: NO workflow .md may invoke `mktemp` -// with a template whose `XXXXXX` run is followed by a filename suffix -// (e.g. `…-XXXXXX.json`, `…-XXXXXX.md`). BSD/macOS `mktemp` only substitutes -// the `X` run when it is the FINAL path component; a trailing suffix yields a -// literal, non-randomized path, so concurrent workflow runs collide on the same -// temp file (one run overwriting or consuming another's). The portable fix is -// `mktemp …-XXXXXX` (suffix-less) then `mv` to add the extension. -// -// This is a copy-paste-prone shell idiom — the same defect first shipped across -// five workflows before #1520 — so a prose guard is the right lock-out, mirroring -// the bug-637 hardcoded-$HOME workflow scan. - -'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 WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows'); - -// Match `mktemp ` where, within the single whitespace-delimited template -// token, a maximal run of 3+ `X` is immediately followed by a filename -// character (`.`, alnum, `-`, `_`) — i.e. a suffix the BSD/macOS substitution -// can't reach. -// - `\s+` requires an argument (bare `mktemp` is fine — path-final -// is N/A — and prose like "mktemp only randomizes XXXXXX" -// is excluded because the X-run is in a later token). -// - `["']?\S*?` walks within the one quoted/unquoted template token. -// - `X{3,}(?!X)` anchors on the WHOLE X-run (so `XXXXXX)` does not match -// via a sub-run leaving a trailing `X`). -// - `[.A-Za-z0-9_-]` the offending suffix char. A legitimate path-final form -// ends the token with `"`, `'`, whitespace, or `)`, none of -// which are in this class. -const SUFFIXED_MKTEMP_TEMPLATE = /mktemp\s+["']?\S*?X{3,}(?!X)[.A-Za-z0-9_-]/; - -function collectWorkflowMarkdown(dir) { - const out = []; - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) { - out.push(...collectWorkflowMarkdown(full)); - } else if (entry.isFile() && entry.name.endsWith('.md')) { - out.push(full); - } - } - return out; -} - -describe('#1520: workflow mktemp templates keep XXXXXX path-final', () => { - test('no gsd-core/workflows/**/*.md calls mktemp with a suffix after the XXXXXX run', () => { - const files = collectWorkflowMarkdown(WORKFLOWS_DIR); - assert.ok(files.length > 0, 'expected workflow markdown files to exist'); - - const offenders = []; - for (const file of files) { - const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/); - lines.forEach((line, i) => { - if (SUFFIXED_MKTEMP_TEMPLATE.test(line)) { - offenders.push(`${path.relative(WORKFLOWS_DIR, file)}:${i + 1}: ${line.trim()}`); - } - }); - } - - assert.deepStrictEqual( - offenders, - [], - 'Workflow mktemp templates must keep XXXXXX as the final path component ' + - '(create suffix-less, then `mv` to add the extension) so BSD/macOS ' + - 'randomizes the path. Offenders:\n' + - offenders.join('\n'), - ); - }); -}); diff --git a/tests/fix-3722-execute-phase-human-needed-checkpoint.test.cjs b/tests/fix-3722-execute-phase-human-needed-checkpoint.test.cjs deleted file mode 100644 index 481a8037e..000000000 --- a/tests/fix-3722-execute-phase-human-needed-checkpoint.test.cjs +++ /dev/null @@ -1,128 +0,0 @@ -// allow-test-rule: source-text-is-the-product -// execute-phase.md IS the runtime contract loaded by the orchestrator. -// Asserting that the "ack-and-advance" path is absent is the only way to verify -// the state machine lie (issue #38) cannot regress at runtime. -'use strict'; - -/** - * execute-phase.md human_needed branch — issue #38 / fix #3722 - * - * The old design offered '"approved" → continue' as a shortcut that advanced - * ROADMAP.md without completing human verification. This is a state machine lie: - * the phase appears complete in the project record while HUMAN-UAT.md items - * remain unresolved. - * - * The correct design: - * - human_needed branch creates a {phase_num}-UAT.md file (not {phase_num}-HUMAN-UAT.md) - * - directs the user to /gsd:verify-work to complete verification - * - does NOT call update_roadmap directly (phase completion goes through verify-work) - * - does NOT offer "approved" → continue as a bypass - */ - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const EXECUTE_PHASE = path.join( - __dirname, - '..', - 'gsd-core', - 'workflows', - 'execute-phase.md' -); - -describe('execute-phase.md human_needed branch — issue #38', () => { - let content; - - // Read once; all tests share the string. - test('workflow file is readable', () => { - content = fs.readFileSync(EXECUTE_PHASE, 'utf-8'); - assert.ok(content.length > 0, 'execute-phase.md must be non-empty'); - }); - - test('human_needed section exists', () => { - if (!content) content = fs.readFileSync(EXECUTE_PHASE, 'utf-8'); - assert.ok( - content.includes('human_needed'), - 'execute-phase.md must contain a human_needed branch' - ); - }); - - test('"approved" → continue bypass is absent from human_needed branch', () => { - if (!content) content = fs.readFileSync(EXECUTE_PHASE, 'utf-8'); - // The old prompt offered '"approved" → continue' as a shortcut that advanced - // ROADMAP.md without completing verification. That path must not exist. - assert.ok( - !content.includes('"approved" → continue'), - 'human_needed branch must not offer "approved" → continue: it marks the phase complete without verification (issue #38)' - ); - }); - - test('human_needed branch does NOT call update_roadmap directly', () => { - if (!content) content = fs.readFileSync(EXECUTE_PHASE, 'utf-8'); - // Locate the human_needed section and check that update_roadmap does not - // appear before the gaps_found section (i.e. it is not reachable from human_needed). - const humanNeededIdx = content.indexOf('**If human_needed:**'); - const gapsFoundIdx = content.indexOf('**If gaps_found:**'); - const updateRoadmapIdx = content.indexOf('update_roadmap', humanNeededIdx); - - assert.ok(humanNeededIdx !== -1, '**If human_needed:** section must exist'); - assert.ok(gapsFoundIdx !== -1, '**If gaps_found:** section must exist'); - assert.ok( - humanNeededIdx < gapsFoundIdx, - 'human_needed section must appear before gaps_found section' - ); - - // update_roadmap must not appear between human_needed and gaps_found sections - const updateRoadmapBetween = - updateRoadmapIdx !== -1 && - updateRoadmapIdx > humanNeededIdx && - updateRoadmapIdx < gapsFoundIdx; - - assert.ok( - !updateRoadmapBetween, - 'update_roadmap must not be reachable directly from the human_needed branch — phase completion must go through verify-work (issue #38)' - ); - }); - - test('human_needed branch directs user to /gsd:verify-work', () => { - if (!content) content = fs.readFileSync(EXECUTE_PHASE, 'utf-8'); - const humanNeededIdx = content.indexOf('**If human_needed:**'); - const gapsFoundIdx = content.indexOf('**If gaps_found:**'); - assert.ok(humanNeededIdx !== -1, '**If human_needed:** section must exist'); - - const humanNeededSection = content.slice( - humanNeededIdx, - gapsFoundIdx !== -1 ? gapsFoundIdx : undefined - ); - assert.ok( - humanNeededSection.includes('verify-work'), - 'human_needed branch must direct the user to /gsd:verify-work to complete verification' - ); - }); - - test('human_needed branch creates {phase_num}-UAT.md (not HUMAN-UAT.md)', () => { - if (!content) content = fs.readFileSync(EXECUTE_PHASE, 'utf-8'); - const humanNeededIdx = content.indexOf('**If human_needed:**'); - const gapsFoundIdx = content.indexOf('**If gaps_found:**'); - assert.ok(humanNeededIdx !== -1, '**If human_needed:** section must exist'); - - const humanNeededSection = content.slice( - humanNeededIdx, - gapsFoundIdx !== -1 ? gapsFoundIdx : undefined - ); - - // The file should be named {phase_num}-UAT.md so verify-work's glob picks it up - assert.ok( - humanNeededSection.includes('-UAT.md'), - 'human_needed branch must create a {phase_num}-UAT.md file for verify-work to resume' - ); - - // HUMAN-UAT.md causes a naming mismatch with verify-work's create_uat_file step - assert.ok( - !humanNeededSection.includes('HUMAN-UAT.md'), - 'human_needed branch must NOT create HUMAN-UAT.md — use {phase_num}-UAT.md to align with verify-work\'s resume path (issue #38 edge case 3)' - ); - }); -}); diff --git a/tests/git-base-branch.test.cjs b/tests/git-base-branch.test.cjs index 402565aac..11353b9ab 100644 --- a/tests/git-base-branch.test.cjs +++ b/tests/git-base-branch.test.cjs @@ -338,3 +338,101 @@ describe('#1406: setGsdConfig prototype-pollution guard', () => { assert.strictEqual(cfg.git.base_branch, 'develop'); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2004-pr-branch-milestone.test.cjs — consolidation epic #1969 (B4 #1973) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2004-pr-branch-milestone (consolidation epic #1969 B4 #1973)", () => { +/** + * Regression tests for bug #2004 + * + * /gsd-pr-branch must not exclude milestone archive and structural planning + * commits. The previous implementation filtered ALL .planning/-only commits, + * including STATE.md, ROADMAP.md, MILESTONES.md, and milestones/** updates + * that are needed to preserve repository planning state after a merge. + * + * Fixed: pr-branch.md now distinguishes: + * - Transient planning commits (phase plans, summaries, research, context) → EXCLUDE + * - Structural planning commits (STATE.md, ROADMAP.md, MILESTONES.md, + * PROJECT.md, milestones/**) → INCLUDE + * - Code commits (any non-.planning/ file) → INCLUDE + * - Mixed commits (code + planning) → INCLUDE + */ + +'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 workflowPath = path.resolve( + __dirname, '..', 'gsd-core', 'workflows', 'pr-branch.md' +); + +describe('bug #2004: pr-branch preserves structural planning commits', () => { + let content; + + test('setup: pr-branch workflow is readable', () => { + content = fs.readFileSync(workflowPath, 'utf-8'); + assert.ok(content.length > 0, 'pr-branch.md must not be empty'); + }); + + test('workflow distinguishes structural vs transient planning commits', () => { + content = content || fs.readFileSync(workflowPath, 'utf-8'); + // Must contain language distinguishing structural from transient/phase planning files + assert.ok( + /structural|milestone.*archive|STATE\.md.*INCLUDE|preserve.*milestone|milestone.*preserve/i.test(content), + 'pr-branch.md must distinguish structural planning commits from transient ones' + ); + }); + + test('workflow lists STATE.md and ROADMAP.md as structural files to preserve', () => { + content = content || fs.readFileSync(workflowPath, 'utf-8'); + assert.ok( + content.includes('STATE.md'), + 'pr-branch.md must reference STATE.md as a structural file to preserve' + ); + assert.ok( + content.includes('ROADMAP.md'), + 'pr-branch.md must reference ROADMAP.md as a structural file to preserve' + ); + }); + + test('workflow lists MILESTONES.md or milestones/ as structural files to preserve', () => { + content = content || fs.readFileSync(workflowPath, 'utf-8'); + assert.ok( + content.includes('MILESTONES.md') || content.includes('milestones/'), + 'pr-branch.md must reference MILESTONES.md or milestones/ as structural files to preserve' + ); + }); + + test('workflow has four commit categories (code, planning-only, mixed, structural)', () => { + content = content || fs.readFileSync(workflowPath, 'utf-8'); + // Must have at least a "structural" or "milestone" category beyond the original three + assert.ok( + /structural.*commit|milestone.*commit|commit.*structural|commit.*milestone/i.test(content) || + /INCLUDE.*STATE\.md|STATE\.md.*INCLUDE/i.test(content), + 'pr-branch.md must classify structural planning commits as INCLUDE' + ); + }); + + test('create_pr_branch step does not rm -r --cached all of .planning/', () => { + content = content || fs.readFileSync(workflowPath, 'utf-8'); + // The original bug: `git rm -r --cached .planning/` nuked structural files. + // The fix must either remove this wholesale rm or scope it to transient dirs. + // Acceptable: narrowed rm targeting only phase/, quick/, research/, etc. + // Not acceptable: `git rm -r --cached .planning/` with no scoping. + const hasUnscoped = /git rm -r --cached \.planning\/(?!\*)?(?!phases|quick|research|threads|todos|debug|seeds|ui-reviews|codebase)/ + .test(content); + assert.ok( + !hasUnscoped, + 'create_pr_branch must not use unscoped "git rm -r --cached .planning/" — scope to transient subdirectories only' + ); + }); +}); + }); +} diff --git a/tests/gsd-settings-advanced.test.cjs b/tests/gsd-settings-advanced.test.cjs index 646ef6fe2..041d833db 100644 --- a/tests/gsd-settings-advanced.test.cjs +++ b/tests/gsd-settings-advanced.test.cjs @@ -385,3 +385,275 @@ describe('gsd-settings-advanced — negative scenarios', () => { assert.strictEqual(stored.workflow.max_discuss_passes, 7); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2506-settings-profile-nonclaude-warning.test.cjs — consolidation epic #1969 (B4 #1973) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2506-settings-profile-nonclaude-warning (consolidation epic #1969 B4 #1973)", () => { +/** + * Regression test for bug #2506 + * + * /gsd-settings presents Quality/Balanced/Budget model profiles without any + * warning that on non-Claude runtimes (Codex, Gemini CLI, etc.) these profiles + * select Claude model tiers and have no effect on actual agent model selection. + * + * Fix: settings.md must include a non-Claude runtime note instructing users to + * use "Inherit" or configure model_overrides manually, and the Inherit option + * description must explicitly call out non-Claude runtimes. + * + * Closes: #2506 + */ + +'use strict'; + +const { describe, test, before } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const SETTINGS_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'settings.md'); + +describe('bug #2506: settings.md non-Claude runtime warning for model profiles', () => { + let content; + + before(() => { + content = fs.readFileSync(SETTINGS_PATH, 'utf-8'); + }); + + test('settings.md contains a non-Claude runtime note for model profiles', () => { + assert.ok( + content.includes('non-Claude runtime') || content.includes('non-Claude runtimes'), + 'settings.md must include a note about non-Claude runtimes and model profiles' + ); + }); + + test('non-Claude note explains profiles are no-ops without model_overrides', () => { + assert.ok( + content.includes('model_overrides') || content.includes('no effect'), + 'note must explain profiles have no effect on non-Claude runtimes without model_overrides' + ); + }); + + test('Inherit option description explicitly mentions non-Claude runtimes', () => { + // The Inherit option in AskUserQuestion must call out non-Claude runtimes + const inheritOptionMatch = content.match(/label:\s*"Inherit"[^}]*description:\s*"([^"]+)"/s); + assert.ok(inheritOptionMatch, 'Inherit option with label/description must exist in settings.md'); + const desc = inheritOptionMatch[1]; + assert.ok( + desc.includes('non-Claude') || desc.includes('Codex') || desc.includes('Gemini'), + `Inherit option description must mention non-Claude runtimes; got: "${desc}"` + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3784-gsd-settings-model-profile-ui-omits-adaptive.test.cjs — consolidation epic #1969 (B4 #1973) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3784-gsd-settings-model-profile-ui-omits-adaptive (consolidation epic #1969 B4 #1973)", () => { +'use strict'; + +// allow-test-rule: source-text-is-the-product (see #3784) +// The deployed settings.md IS the product — testing its text content tests the deployed contract. + +/** + * Regression test for bug #3784 + * + * /gsd-settings model profile UI omits `adaptive`. The AskUserQuestion block + * for model_profile in settings.md lists only four options (Quality, Balanced, + * Budget, Inherit) but the settings schema registers five valid profiles: + * quality, balanced, budget, adaptive, inherit. The `adaptive` profile is + * reachable by name via `gsd:config --profile adaptive` but cannot be selected + * interactively through `/gsd:settings`. + * + * Root cause: the options array in the model-profile AskUserQuestion block was + * written before the `adaptive` profile was introduced and was never updated. + * Because AskUserQuestion enforces a hard 4-option cap, the fix uses a two- + * question split: Q1 asks "Standard tier or Adaptive?" (2 options); if the + * user picks Standard, Q2 asks which of the three standard profiles to use + * (Quality, Balanced, Budget). This keeps every call within the 4-option cap + * while making all five profiles reachable. + * + * Fixes: #3784 + */ + +const { describe, test, before } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const SETTINGS_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'settings.md'); + +/** + * Extract all AskUserQuestion option labels from a text block. + * Returns them lowercased for case-insensitive comparison. + */ +function extractOptionLabels(block) { + const labelPattern = /label:\s*"([^"]+)"/g; + const labels = []; + let match; + while ((match = labelPattern.exec(block)) !== null) { + labels.push(match[1].toLowerCase()); + } + return labels; +} + +describe('bug #3784: settings.md model profile UI exposes all 5 profiles', () => { + let content; + let presentBlock; + + before(() => { + content = fs.readFileSync(SETTINGS_PATH, 'utf-8'); + const presentMatch = content.match(/[\s\S]*?<\/step>/); + assert.ok(presentMatch, 'settings.md must have a present_settings step'); + presentBlock = presentMatch[0]; + }); + + // ── Core contract: all five valid profiles reachable via the settings UI ── + + test('present_settings step includes Adaptive as a selectable option (#3784)', () => { + // This is the primary assertion for bug #3784 — adaptive was missing. + const labels = extractOptionLabels(presentBlock); + assert.ok( + labels.some(l => l === 'adaptive' || l.startsWith('adaptive')), + [ + 'Bug #3784: present_settings step must include an "Adaptive" label in', + 'its model profile AskUserQuestion options so users can select it', + `interactively. Got labels: [${labels.join(', ')}]`, + ].join(' ') + ); + }); + + test('present_settings step includes Quality as a selectable option', () => { + const labels = extractOptionLabels(presentBlock); + assert.ok( + labels.some(l => l === 'quality' || l.startsWith('quality')), + `present_settings step must include a "Quality" option. Got: [${labels.join(', ')}]` + ); + }); + + test('present_settings step includes Balanced as a selectable option', () => { + const labels = extractOptionLabels(presentBlock); + assert.ok( + labels.some(l => l === 'balanced' || l.startsWith('balanced')), + `present_settings step must include a "Balanced" option. Got: [${labels.join(', ')}]` + ); + }); + + test('present_settings step includes Budget as a selectable option', () => { + const labels = extractOptionLabels(presentBlock); + assert.ok( + labels.some(l => l === 'budget' || l.startsWith('budget')), + `present_settings step must include a "Budget" option. Got: [${labels.join(', ')}]` + ); + }); + + test('present_settings step includes Inherit as a selectable option', () => { + const labels = extractOptionLabels(presentBlock); + assert.ok( + labels.some(l => l === 'inherit' || l.startsWith('inherit')), + `present_settings step must include an "Inherit" option. Got: [${labels.join(', ')}]` + ); + }); + + // ── update_config step writes adaptive as a valid value ── + + test('update_config step lists adaptive as a valid model_profile value', () => { + const updateMatch = content.match(/[\s\S]*?<\/step>/); + assert.ok(updateMatch, 'settings.md must have an update_config step'); + const block = updateMatch[0]; + assert.ok( + block.includes('adaptive'), + 'update_config step must list "adaptive" as a valid model_profile value' + ); + }); + + // ── confirm step displays adaptive as a possible profile value ── + + test('confirm step table shows adaptive as a possible model profile value', () => { + const confirmMatch = content.match(/[\s\S]*?<\/step>/); + assert.ok(confirmMatch, 'settings.md must have a confirm step'); + const block = confirmMatch[0]; + assert.ok( + block.includes('adaptive'), + 'confirm step must include "adaptive" in the Model Profile row placeholder' + ); + }); + + // ── adaptive described with role-based routing semantics ── + + test('settings.md describes adaptive profile with role-based routing semantics', () => { + // Adaptive uses heavy/light role tiers per routingTier. + // The UI description must convey role-based cost optimization and the heavy/light tier + // split — not just mention "Adaptive" somewhere (that word appears 6+ times in the file). + const lower = content.toLowerCase(); + assert.ok( + lower.includes('role-based cost optimization') && lower.includes('heavy roles'), + 'settings.md must describe the adaptive profile with "role-based cost optimization" and "heavy roles" wording so the description is meaningful across all supported runtimes' + ); + }); + + // ── 4-option cap enforcement ── + + test('each question object in present_settings AskUserQuestion blocks has at most 4 options (AskUserQuestion runtime cap)', () => { + // The AskUserQuestion runtime enforces a hard 4-option cap per individual question object + // (each { question:..., options:[...] } entry). This test guards against a naïve revert + // that puts all 5 profiles into a single question object instead of using the Q1/Q2 split. + const ASK_USER_QUESTION_OPTION_CAP = 4; // hard limit enforced by the AskUserQuestion runtime + + // Extract each individual options array by finding 'options: [' and walking to the + // matching balanced ']', then count label: entries within that span. + const optionsKeyRe = /\boptions\s*:\s*\[/g; + let match; + let questionIndex = 0; + while ((match = optionsKeyRe.exec(presentBlock)) !== null) { + questionIndex++; + // Walk forward from the opening '[' to find the balanced close ']'. + let depth = 0; + const start = match.index + match[0].length - 1; // points at '[' + let end = start; + for (let k = start; k < presentBlock.length; k++) { + if (presentBlock[k] === '[') { depth++; } + else if (presentBlock[k] === ']') { + depth--; + if (depth === 0) { end = k; break; } + } + } + const optionsBody = presentBlock.slice(start, end + 1); + const labelMatches = optionsBody.match(/label:\s*"[^"]+"/g) || []; + const optionCount = labelMatches.length; + assert.ok( + optionCount <= ASK_USER_QUESTION_OPTION_CAP, + `Question object ${questionIndex} in present_settings has ${optionCount} options — exceeds the runtime cap of ${ASK_USER_QUESTION_OPTION_CAP}. Split into multiple questions (as #3784 did for model_profile).` + ); + } + // Sanity check: there must be at least one options array found. + assert.ok(questionIndex > 0, 'present_settings must contain at least one AskUserQuestion options array'); + }); + + // ── Brace-balance regression (bd53925f fixed duplicate '{' from 35fc1d21) ── + + test('present_settings step has balanced braces — regression: brace-balance after #3784 split', () => { + // commit bd53925f fixed a duplicate '{' introduced by 35fc1d21 when the model-profile + // AskUserQuestion was split into Q1+Q2. This test guards against a recurrence. + let depth = 0; + for (const ch of presentBlock) { + if (ch === '{') { depth++; } + if (ch === '}') { depth--; } + } + assert.strictEqual( + depth, + 0, + `present_settings step has unbalanced braces: net depth after full scan is ${depth} (positive = extra '{', negative = extra '}'). Regression guard for bd53925f / #3784.` + ); + }); +}); + }); +} diff --git a/tests/issue-498-identity-drift-lint.test.cjs b/tests/issue-498-identity-drift-lint.test.cjs index d09dca102..c5dcf2131 100644 --- a/tests/issue-498-identity-drift-lint.test.cjs +++ b/tests/issue-498-identity-drift-lint.test.cjs @@ -67,3 +67,56 @@ describe('Issue #498: the live repo passes the drift lint', () => { ); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-170-workflow-fallback-install-hint.test.cjs — consolidation epic #1969 (B4 #1973) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-170-workflow-fallback-install-hint (consolidation epic #1969 B4 #1973)", () => { +'use strict'; +// allow-test-rule: workflow markdown is shipped product text; this test validates fallback hint literals across all workflow files (see #170) + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows'); +const LEGACY_HINT = 'npx get-shit-done-cc@latest --claude --local'; +const CURRENT_HINT = 'npx -y @opengsd/gsd-core@latest --claude --local'; + +function findMarkdownFiles(dir) { + const out = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) out.push(...findMarkdownFiles(full)); + else if (entry.isFile() && full.endsWith('.md')) out.push(full); + } + return out; +} + +test('bug #170: workflow fallback hints do not reference get-shit-done-cc', () => { + const files = findMarkdownFiles(WORKFLOWS_DIR); + let legacyCount = 0; + let currentCount = 0; + + for (const file of files) { + const src = fs.readFileSync(file, 'utf8'); + if (src.includes(LEGACY_HINT)) legacyCount += 1; + if (src.includes(CURRENT_HINT)) currentCount += 1; + } + + assert.equal( + legacyCount, + 0, + `workflow fallback hints must not reference legacy package (${LEGACY_HINT})` + ); + assert.ok( + currentCount > 0, + `expected at least one workflow fallback hint to use current package (${CURRENT_HINT})` + ); +}); + }); +} diff --git a/tests/issue-815-update-next-channel.test.cjs b/tests/issue-815-update-next-channel.test.cjs index 5ad08ec4f..da853f91f 100644 --- a/tests/issue-815-update-next-channel.test.cjs +++ b/tests/issue-815-update-next-channel.test.cjs @@ -42,3 +42,115 @@ test('issue #815: command documents --next/--rc and routes it to the update work assert.match(CMD, /--rc/); assert.match(CMD, /argument-hint:.*--next/); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2470-update-md-claude-path.test.cjs — consolidation epic #1969 (B4 #1973) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2470-update-md-claude-path (consolidation epic #1969 B4 #1973)", () => { +// allow-test-rule: source-text-is-the-product (see #2470) +// Workflow .md / agent .md / command .md / reference .md files — their text +// IS what the runtime loads. Testing text content tests the deployed contract. +// Per CONTRIBUTING.md exception matrix. +'use strict'; + + +/** + * Regression test for #2470. + * + * update.md is installed into every runtime directory including .gemini, .codex, + * .opencode, etc. The installer's scanForLeakedPaths() uses the regex + * /(?:~|\$HOME)\/\.claude\b/g to detect unresolved .claude path references after + * copyWithPathReplacement() runs. The replacer handles "~/.claude/" (trailing slash) + * but not "~/.claude" (bare, no trailing slash) — so any bare reference in + * update.md would slip through and trigger the installer warning for non-Claude runtimes. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const UPDATE_MD = path.join(__dirname, '..', 'gsd-core', 'workflows', 'update.md'); + +describe('update.md — no bare ~.claude path references (#2470)', () => { + const content = fs.readFileSync(UPDATE_MD, 'utf-8'); + + test('update.md does not contain bare ~/\\.claude (without trailing slash)', () => { + // This is the exact pattern from the installer's scanForLeakedPaths(): + // /(?:~|\$HOME)\/\.claude\b/g + // The replacer handles ~/\.claude\/ (with trailing slash) but misses bare ~/\.claude + // so we must not have bare references in the source file. + const matches = content.match(/(?:~|\$HOME)\/\.claude(?!\/)/g); + assert.strictEqual( + matches, + null, + `update.md must not contain bare ~/.claude (without trailing slash) — installer scanner flags these as unresolved path refs: ${JSON.stringify(matches)}` + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3130-update-npx-robust-invocation.test.cjs — consolidation epic #1969 (B4 #1973) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3130-update-npx-robust-invocation (consolidation epic #1969 B4 #1973)", () => { +'use strict'; +// allow-test-rule: reads product workflow markdown (update.md) to verify structural invocation contract — not a source-grep test (see #3130) + +// Regression guard for bug #3130. +// +// Two failure modes were observed with the pre-fix npx invocation form: +// 1. Cache-stale: bare `npx -y @opengsd/gsd-core@` hits npx's local +// cache and may pull an older version instead of the target tag. +// 2. Token-routing: Bash-tool wrappers misroute the `@` token in +// `@opengsd/gsd-core@`, causing npm to error with +// "Unknown command: @opengsd/gsd-core@". +// +// The robust form is: +// npx -y --package=@opengsd/gsd-core@"$TAG" -- gsd-core $ARGS +// +// `--package=` forces a fresh registry fetch, bypassing the npx cache. +// `--` clearly delineates npx flags from the run-command, preventing +// Bash-tool @-token misrouting. +// `$TAG` is a shell variable (latest by default, next under --next/--rc), +// set by the parse_update_channel step (#815). + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const ROOT = path.join(__dirname, '..'); +const UPDATE_WF = path.join(ROOT, 'gsd-core', 'workflows', 'update.md'); + +const src = fs.readFileSync(UPDATE_WF, 'utf8'); + +test('bug #3130: update.md contains no bare npx invocations (cache-stale form)', () => { + // Any occurrence of `npx -y @opengsd/gsd-core@` without `--package=` + // is the stale form that triggers the two failure modes. + const stale = (src.match(/npx -y @opengsd\/gsd-core@\S+[^\r\n]*/g) || []); + assert.deepEqual( + stale, + [], + `Stale npx forms found in update.md (must use --package= form): ${stale.join('; ')}`, + ); +}); + +test('bug #3130: update.md has >=3 robust npx invocations (--package= + -- separator)', () => { + // Three sibling invocations: local, global, and unknown/fallback. + // The tag is now a $TAG variable (latest by default, next under --next/--rc). + const robust = (src.match(/npx -y --package=@opengsd\/gsd-core@\S+ -- gsd-core/g) || []); + assert.ok( + robust.length >= 3, + `Expected >=3 robust npx invocations in update.md, found ${robust.length}`, + ); +}); + }); +} diff --git a/tests/new-milestone-clear-phases.test.cjs b/tests/new-milestone-clear-phases.test.cjs index 7da7c14da..2cc7ff8fe 100644 --- a/tests/new-milestone-clear-phases.test.cjs +++ b/tests/new-milestone-clear-phases.test.cjs @@ -242,3 +242,100 @@ describe('phases clear: uncommitted-changes guard (#1447)', () => { } }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-2433-todo-phase-linking.test.cjs — consolidation epic #1969 (B4 #1973) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-2433-todo-phase-linking (consolidation epic #1969 B4 #1973)", () => { +'use strict'; + +// allow-test-rule: source-text-is-the-product (see #2433) +// Reads .md/.json/.yml product files whose deployed text IS what the +// runtime loads — testing text content tests the deployed contract. + +/** + * Tests for gsd-new-milestone todo-to-phase linking (#2433). + * Verifies the workflow text contains the correct linking and auto-close steps. + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const ROOT = path.resolve(__dirname, '..'); + +const NEW_MILESTONE = fs.readFileSync( + path.join(ROOT, 'gsd-core/workflows/new-milestone.md'), 'utf-8' +); +const EXECUTE_PHASE = fs.readFileSync( + path.join(ROOT, 'gsd-core/workflows/execute-phase.md'), 'utf-8' +); + +test('new-milestone.md: step 10.5 links pending todos to roadmap phases', () => { + assert.ok(NEW_MILESTONE.includes('10.5'), 'step 10.5 should exist'); + assert.ok(NEW_MILESTONE.includes('resolves_phase'), 'should reference resolves_phase field'); + assert.ok(NEW_MILESTONE.includes('.planning/todos/pending'), 'should scan pending todos directory'); +}); + +test('new-milestone.md: todo linking runs after roadmap commit', () => { + const roadmapCommitIdx = NEW_MILESTONE.indexOf('docs: create milestone v[X.Y] roadmap'); + const step105Idx = NEW_MILESTONE.indexOf('10.5. Link Pending Todos'); + const step11Idx = NEW_MILESTONE.indexOf('## 11. Done'); + assert.ok(roadmapCommitIdx < step105Idx, 'step 10.5 should come after roadmap commit'); + assert.ok(step105Idx < step11Idx, 'step 10.5 should come before step 11'); +}); + +test('new-milestone.md: todo linking is best-effort and leaves unmatched todos unmodified', () => { + assert.ok(NEW_MILESTONE.includes('best-effort'), 'should describe best-effort matching'); + assert.ok(NEW_MILESTONE.includes('unmatched'), 'should mention leaving unmatched todos alone'); + assert.ok(NEW_MILESTONE.includes('confident match'), 'should gate on confident match'); +}); + +test('new-milestone.md: step 10.5 commits tagged todos', () => { + // After #3797 architectural fix, callsites use gsd_run + assert.ok(NEW_MILESTONE.includes('gsd_run query commit'), 'should commit tagged todos'); + assert.ok(NEW_MILESTONE.includes('resolves_phase after milestone'), 'commit message should mention resolves_phase'); +}); + +test('new-milestone.md: success_criteria includes todo linking', () => { + assert.ok(NEW_MILESTONE.includes('resolves_phase: N'), 'success_criteria should mention resolves_phase tagging'); +}); + +test('execute-phase.md: close_phase_todos step exists', () => { + assert.ok(EXECUTE_PHASE.includes('close_phase_todos'), 'close_phase_todos step should exist'); + assert.ok(EXECUTE_PHASE.includes('resolves_phase'), 'should check resolves_phase in todos'); +}); + +test('execute-phase.md: auto-close moves todos to completed directory', () => { + assert.ok(EXECUTE_PHASE.includes('.planning/todos/completed'), 'should move to completed dir'); + assert.ok(EXECUTE_PHASE.includes('.planning/todos/pending'), 'should scan pending dir'); + assert.ok(EXECUTE_PHASE.includes('mv "$TODO_FILE" "$COMPLETED_DIR/"'), 'should use mv to move files'); +}); + +test('execute-phase.md: close_phase_todos runs after update_roadmap', () => { + const updateRoadmapIdx = EXECUTE_PHASE.indexOf('name="update_roadmap"'); + const closeTodosIdx = EXECUTE_PHASE.indexOf('name="close_phase_todos"'); + assert.ok(updateRoadmapIdx < closeTodosIdx, 'close_phase_todos should run after update_roadmap'); +}); + +test('execute-phase.md: auto-close never blocks phase completion', () => { + const closeTodosSection = EXECUTE_PHASE.slice( + EXECUTE_PHASE.indexOf('name="close_phase_todos"'), + EXECUTE_PHASE.indexOf('name="update_project_md"') + ); + assert.ok( + closeTodosSection.includes('never blocks') || closeTodosSection.includes('additive'), + 'close_phase_todos should be non-blocking' + ); +}); + +test('execute-phase.md: awk extracts resolves_phase from YAML frontmatter', () => { + assert.ok(EXECUTE_PHASE.includes('awk'), 'should use awk for frontmatter extraction'); + assert.ok(EXECUTE_PHASE.includes('resolves_phase:'), 'awk pattern should match resolves_phase key'); +}); + }); +} diff --git a/tests/next-up-clear-order.test.cjs b/tests/next-up-clear-order.test.cjs index 405c41c38..aa34c027c 100644 --- a/tests/next-up-clear-order.test.cjs +++ b/tests/next-up-clear-order.test.cjs @@ -166,3 +166,41 @@ describe('reference files — no /clear patterns', () => { ); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3083-resume-route-clear.test.cjs — consolidation epic #1969 (B4 #1973) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3083-resume-route-clear (consolidation epic #1969 B4 #1973)", () => { +'use strict'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +describe('bug #3083: resume-project next-step routing should not include /clear then:', () => { + test('route_to_workflow block omits /clear then: in resume templates', () => { + const workflowPath = path.join(__dirname, '..', 'gsd-core', 'workflows', 'resume-project.md'); + const content = fs.readFileSync(workflowPath, 'utf-8'); + const routeStart = content.indexOf(''); + const routeEnd = content.indexOf('', routeStart); + const routeBlock = content.slice(routeStart, routeEnd); + + assert.equal(routeBlock.includes('/clear` then:'), false, 'resume route templates must not include `/clear` then:'); + }); + + test('route_to_workflow block includes exception note explaining resume behavior', () => { + const workflowPath = path.join(__dirname, '..', 'gsd-core', 'workflows', 'resume-project.md'); + const content = fs.readFileSync(workflowPath, 'utf-8'); + const routeStart = content.indexOf(''); + const routeEnd = content.indexOf('', routeStart); + const routeBlock = content.slice(routeStart, routeEnd); + + assert.match(routeBlock, /resume.*exception|exception.*resume/i); + }); +}); + }); +} diff --git a/tests/no-hardcoded-home-gsd-tools.test.cjs b/tests/no-hardcoded-home-gsd-tools.test.cjs index 637853a5b..deaba8271 100644 --- a/tests/no-hardcoded-home-gsd-tools.test.cjs +++ b/tests/no-hardcoded-home-gsd-tools.test.cjs @@ -119,3 +119,168 @@ test('each scan dir (agents/, commands/, gsd-core/references/) exists and contai `Per-directory bash-block floor failed:\n${failures.join('\n')}\nCheck that each scan dir is non-empty and contains bash fences.` ); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-637-workflow-no-hardcoded-home-tool.test.cjs — consolidation epic #1969 (B4 #1973) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-637-workflow-no-hardcoded-home-tool (consolidation epic #1969 B4 #1973)", () => { +// allow-test-rule: source-text-is-the-product (see #637) +// Workflow .md text IS what the runtime loads and the agent executes, so +// asserting on its shell invocations tests the deployed contract directly. +// +// Repo-wide regression guard for #637 (generalizes the plan-phase-only guard +// from #621): NO workflow .md may invoke gsd-tools via a hardcoded +// `node "$HOME/.../gsd-tools.cjs"` path. On a global/shim-only install with no +// project-local runtime, that path can miss a working install, so the step +// reports the tool "not found" instead of resolving it. Every invocation must +// go through the `gsd_run` launcher (defined once per file in the canonical +// preamble, which resolves RUNTIME_DIR → .claude → PATH → $HOME in order). +// +// The parity test (runtime-launcher-parity) guards the retired $GSD_SDK and +// bare /gsd-tools tokens but NOT this hardcoded-node form — which is exactly +// how it survived across plan-phase.md (#621) and three more files (#637). + +'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 WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows'); + +// Hardcoded direct invocation form. Distinct from the canonical preamble, which +// references $HOME only inside a `[ -f "$HOME/..." ]` probe / `GSD_TOOLS=` +// assignment and always invokes `node "$GSD_TOOLS"` — never `node "$HOME/..."`. +const HARDCODED_HOME_INVOCATION = /node\s+"\$HOME\/[^"]*gsd-tools\.cjs"/; + +function collectWorkflowMarkdown(dir) { + const out = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...collectWorkflowMarkdown(full)); + } else if (entry.isFile() && entry.name.endsWith('.md')) { + out.push(full); + } + } + return out; +} + +describe('bug #637: no workflow .md hardcodes a $HOME gsd-tools invocation', () => { + test('every gsd-core/workflows/**/*.md resolves gsd-tools via gsd_run, not a hardcoded $HOME path', () => { + const files = collectWorkflowMarkdown(WORKFLOWS_DIR); + assert.ok(files.length > 0, 'expected workflow markdown files to exist'); + + const offenders = []; + for (const file of files) { + const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/); + lines.forEach((line, i) => { + if (HARDCODED_HOME_INVOCATION.test(line)) { + offenders.push(`${path.relative(WORKFLOWS_DIR, file)}:${i + 1}: ${line.trim()}`); + } + }); + } + + assert.deepStrictEqual( + offenders, + [], + 'Workflow files must invoke gsd-tools via the resolved `gsd_run` launcher, ' + + 'not a hardcoded `node "$HOME/.../gsd-tools.cjs"` path. Offenders:\n' + + offenders.join('\n'), + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/fix-1520-workflow-mktemp-suffix-final.test.cjs — consolidation epic #1969 (B4 #1973) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:fix-1520-workflow-mktemp-suffix-final (consolidation epic #1969 B4 #1973)", () => { +// allow-test-rule: source-text-is-the-product (#1520) +// Workflow .md text IS what the runtime loads and the agent executes, so +// asserting on its shell invocations tests the deployed contract directly. +// +// Repo-wide regression guard for #1520: NO workflow .md may invoke `mktemp` +// with a template whose `XXXXXX` run is followed by a filename suffix +// (e.g. `…-XXXXXX.json`, `…-XXXXXX.md`). BSD/macOS `mktemp` only substitutes +// the `X` run when it is the FINAL path component; a trailing suffix yields a +// literal, non-randomized path, so concurrent workflow runs collide on the same +// temp file (one run overwriting or consuming another's). The portable fix is +// `mktemp …-XXXXXX` (suffix-less) then `mv` to add the extension. +// +// This is a copy-paste-prone shell idiom — the same defect first shipped across +// five workflows before #1520 — so a prose guard is the right lock-out, mirroring +// the bug-637 hardcoded-$HOME workflow scan. + +'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 WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows'); + +// Match `mktemp ` where, within the single whitespace-delimited template +// token, a maximal run of 3+ `X` is immediately followed by a filename +// character (`.`, alnum, `-`, `_`) — i.e. a suffix the BSD/macOS substitution +// can't reach. +// - `\s+` requires an argument (bare `mktemp` is fine — path-final +// is N/A — and prose like "mktemp only randomizes XXXXXX" +// is excluded because the X-run is in a later token). +// - `["']?\S*?` walks within the one quoted/unquoted template token. +// - `X{3,}(?!X)` anchors on the WHOLE X-run (so `XXXXXX)` does not match +// via a sub-run leaving a trailing `X`). +// - `[.A-Za-z0-9_-]` the offending suffix char. A legitimate path-final form +// ends the token with `"`, `'`, whitespace, or `)`, none of +// which are in this class. +const SUFFIXED_MKTEMP_TEMPLATE = /mktemp\s+["']?\S*?X{3,}(?!X)[.A-Za-z0-9_-]/; + +function collectWorkflowMarkdown(dir) { + const out = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...collectWorkflowMarkdown(full)); + } else if (entry.isFile() && entry.name.endsWith('.md')) { + out.push(full); + } + } + return out; +} + +describe('#1520: workflow mktemp templates keep XXXXXX path-final', () => { + test('no gsd-core/workflows/**/*.md calls mktemp with a suffix after the XXXXXX run', () => { + const files = collectWorkflowMarkdown(WORKFLOWS_DIR); + assert.ok(files.length > 0, 'expected workflow markdown files to exist'); + + const offenders = []; + for (const file of files) { + const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/); + lines.forEach((line, i) => { + if (SUFFIXED_MKTEMP_TEMPLATE.test(line)) { + offenders.push(`${path.relative(WORKFLOWS_DIR, file)}:${i + 1}: ${line.trim()}`); + } + }); + } + + assert.deepStrictEqual( + offenders, + [], + 'Workflow mktemp templates must keep XXXXXX as the final path component ' + + '(create suffix-less, then `mv` to add the extension) so BSD/macOS ' + + 'randomizes the path. Offenders:\n' + + offenders.join('\n'), + ); + }); +}); + }); +} diff --git a/tests/phase.test.cjs b/tests/phase.test.cjs index 5632a3090..1dad00845 100644 --- a/tests/phase.test.cjs +++ b/tests/phase.test.cjs @@ -8007,3 +8007,79 @@ describe('issue #1159 (Defect B): deferred/future requirement IDs must not trigg }); }); } + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2502-insert-phase-state-update.test.cjs — consolidation epic #1969 (B4 #1973) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2502-insert-phase-state-update (consolidation epic #1969 B4 #1973)", () => { +// allow-test-rule: source-text-is-the-product (see #2502) +// Workflow .md / agent .md / command .md / reference .md files — their text +// IS what the runtime loads. Testing text content tests the deployed contract. +// Per CONTRIBUTING.md exception matrix. + +/** + * Regression test for #2502: insert-phase does not update STATE.md's + * next-phase recommendation after inserting a decimal phase. + * + * Root cause: insert-phase.md's update_project_state step only added a + * "Roadmap Evolution" note to STATE.md, but never updated the "Current Phase" + * / next-run recommendation to point at the newly inserted phase. + * + * Fix: insert-phase.md must include a step that updates STATE.md's next-phase + * pointer (current_phase / next recommended run) to the newly inserted phase. + */ + +'use strict'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const INSERT_PHASE_PATH = path.join( + __dirname, '..', 'gsd-core', 'workflows', 'insert-phase.md' +); + +describe('bug-2502: insert-phase must update STATE.md next-phase recommendation', () => { + test('insert-phase.md exists', () => { + assert.ok(fs.existsSync(INSERT_PHASE_PATH), 'insert-phase.md should exist'); + }); + + test('insert-phase.md contains a STATE.md next-phase update instruction', () => { + const content = fs.readFileSync(INSERT_PHASE_PATH, 'utf-8'); + + // Must reference STATE.md and the concept of updating the next/current phase pointer + const mentionsStateUpdate = ( + /STATE\.md.{0,200}(next.phase|current.phase|next.run|recommendation)/is.test(content) || + /(next.phase|current.phase|next.run|recommendation).{0,200}STATE\.md/is.test(content) + ); + + assert.ok( + mentionsStateUpdate, + 'insert-phase.md must instruct updating STATE.md\'s next-phase recommendation to point to the newly inserted phase' + ); + }); + + test('insert-phase.md update_project_state step covers next-phase pointer', () => { + const content = fs.readFileSync(INSERT_PHASE_PATH, 'utf-8'); + + const stepMatch = content.match(/([\s\S]*?)<\/step>/i); + assert.ok(stepMatch, 'insert-phase.md must contain update_project_state step'); + const stepContent = stepMatch[1]; + + const hasNextPhasePointerUpdate = ( + /\bcurrent[_ -]?phase\b/i.test(stepContent) || + /\bnext[_ -]?phase\b/i.test(stepContent) || + /\bnext recommended run\b/i.test(stepContent) + ); + + assert.ok( + hasNextPhasePointerUpdate, + 'insert-phase.md update_project_state step must update STATE.md\'s next-phase pointer (current_phase) to the inserted decimal phase' + ); + }); +}); + }); +} diff --git a/tests/plan-phase-drift-guard.test.cjs b/tests/plan-phase-drift-guard.test.cjs index eb8699015..1c9a070f4 100644 --- a/tests/plan-phase-drift-guard.test.cjs +++ b/tests/plan-phase-drift-guard.test.cjs @@ -981,3 +981,722 @@ describe('enh #3209: plan-phase ADR ingest express path', () => { }); }); } + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-621-plan-phase-gap-analysis-gsd-run.test.cjs — consolidation epic #1969 (B4 #1973) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-621-plan-phase-gap-analysis-gsd-run (consolidation epic #1969 B4 #1973)", () => { +// allow-test-rule: source-text-is-the-product (see #621) +// The post-planning-gaps gap-analysis invocation is deployed workflow text the +// runtime executes; the contract is that it routes through the gsd_run launcher, +// not a hardcoded $HOME path (#621). + +/** + * Regression test for #621: plan-phase gap-analysis must route through gsd_run + * + * Prior to the fix, line 1631 of plan-phase.md hardcoded: + * node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" gap-analysis ... + * twice on the same line, breaking non-default install layouts. + * + * After the fix, both invocations route through gsd_run (the launcher defined + * at line ~34 of the same file that resolves gsd-tools.cjs against + * RUNTIME_DIR / git-toplevel / PATH / $HOME in order). + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const WORKFLOW_PATH = path.join( + __dirname, + '..', + 'gsd-core', + 'workflows', + 'plan-phase.md' +); + +// ─── Fixture ────────────────────────────────────────────────────────────────── + +const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf8'); + +// ─── #621 regression: gap-analysis routes through gsd_run ──────────────────── + +describe('plan-phase workflow: post-planning-gaps gap-analysis uses gsd_run launcher (#621)', () => { + test('gap-analysis dispatches via gsd_run loop render-hooks plan:post (ADR-857 capability gate)', () => { + assert.ok( + workflow.includes('gsd_run loop render-hooks plan:post'), + 'workflow must dispatch gap-analysis via gsd_run loop render-hooks plan:post, not a hardcoded node path or direct gsd_run gap-analysis call' + ); + }); + + test('inner phase_req_ids query also routes through gsd_run', () => { + assert.ok( + workflow.includes('gsd_run query init.plan-phase'), + 'workflow must invoke the inner phase_req_ids query via gsd_run launcher' + ); + }); + + test('no hardcoded node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" invocations remain (#621)', () => { + const hardcodedCount = ( + workflow.match(/node "\$HOME\/\.claude\/gsd-core\/bin\/gsd-tools\.cjs"/g) || [] + ).length; + assert.strictEqual( + hardcodedCount, + 0, + [ + '#621 regression: workflow must not contain any hardcoded', + 'node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" invocations;', + `found ${hardcodedCount}`, + ].join(' ') + ); + }); + + test('post-planning-gaps block still gates on workflow.post_planning_gaps and preserves required args', () => { + const hasGate = workflow.includes('workflow.post_planning_gaps'); + const hasPhaseDir = workflow.includes('gsd_run check ${hook.check.query} "${PHASE_DIR}" "${PHASE_REQ_IDS}"'); + const hasPickArg = workflow.includes('--pick phase_req_ids'); + assert.ok( + hasGate, + 'workflow must still gate the gap-analysis step on workflow.post_planning_gaps config key' + ); + assert.ok( + hasPhaseDir, + 'gap-analysis check dispatch must pass "${PHASE_DIR}" (and "${PHASE_REQ_IDS}") positionally to gsd_run check' + ); + assert.ok( + hasPickArg, + 'inner query must still pass --pick phase_req_ids to extract phase requirement IDs' + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/feat-22-surfacing-docs.test.cjs — consolidation epic #1969 (B4 #1973) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:feat-22-surfacing-docs (consolidation epic #1969 B4 #1973)", () => { +// allow-test-rule: docs-parity (see #22) +// Verifies that issue #22 drift-guard surfacing changes are present: +// - new-project workflow mentions plan_review.source_grounding +// - CONFIGURATION.md documents both new config keys +// - COMMANDS.md mentions gsd-tools intel api-surface + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.join(__dirname, '..'); + +const NEW_PROJECT_PATH = path.join(ROOT, 'gsd-core', 'workflows', 'new-project.md'); +const SETTINGS_PATH = path.join(ROOT, 'gsd-core', 'workflows', 'settings.md'); +const CONFIGURATION_PATH = path.join(ROOT, 'docs', 'CONFIGURATION.md'); +const COMMANDS_PATH = path.join(ROOT, 'docs', 'COMMANDS.md'); +const USER_GUIDE_PATH = path.join(ROOT, 'docs', 'USER-GUIDE.md'); +const ARCHITECTURE_PATH = path.join(ROOT, 'docs', 'ARCHITECTURE.md'); + +describe('feat-22-surfacing-docs', () => { + // ── A1: new-project workflow ───────────────────────────────────────────── + + test('new-project workflow mentions source_grounding', () => { + const content = fs.readFileSync(NEW_PROJECT_PATH, 'utf-8'); + assert.ok( + content.includes('source_grounding'), + 'new-project.md must mention source_grounding' + ); + }); + + test('new-project workflow has Drift Guard question', () => { + const content = fs.readFileSync(NEW_PROJECT_PATH, 'utf-8'); + assert.ok( + content.includes('Drift Guard'), + 'new-project.md must include a "Drift Guard" question header' + ); + }); + + test('new-project workflow wires source_grounding into config-new-project call', () => { + const content = fs.readFileSync(NEW_PROJECT_PATH, 'utf-8'); + assert.ok( + content.includes('"plan_review":{"source_grounding":'), + 'new-project.md config-new-project call must include plan_review.source_grounding' + ); + }); + + test('new-project workflow has Drift Guard default-yes option', () => { + const content = fs.readFileSync(NEW_PROJECT_PATH, 'utf-8'); + // Both question blocks (auto and interactive) should have the yes option + const count = (content.match(/Yes \(Recommended\).*catches hallucinated names/g) || []).length; + assert.ok( + count >= 1, + 'new-project.md must have at least one Drift Guard "Yes (Recommended)" option' + ); + }); + + // ── A2: settings workflow ──────────────────────────────────────────────── + + test('settings workflow mentions source_grounding in read_current step', () => { + const content = fs.readFileSync(SETTINGS_PATH, 'utf-8'); + assert.ok( + content.includes('plan_review.source_grounding'), + 'settings.md must mention plan_review.source_grounding in the read_current step' + ); + }); + + test('settings workflow has Drift Guard AskUserQuestion toggle', () => { + const content = fs.readFileSync(SETTINGS_PATH, 'utf-8'); + assert.ok( + content.includes('Drift Guard'), + 'settings.md must include a "Drift Guard" question header' + ); + }); + + test('settings workflow update_config includes plan_review.source_grounding', () => { + const content = fs.readFileSync(SETTINGS_PATH, 'utf-8'); + assert.ok( + content.includes('"source_grounding": true/false'), + 'settings.md update_config block must include source_grounding: true/false' + ); + }); + + test('settings workflow mentions source_grounding_authority', () => { + const content = fs.readFileSync(SETTINGS_PATH, 'utf-8'); + assert.ok( + content.includes('source_grounding_authority'), + 'settings.md must mention source_grounding_authority' + ); + }); + + test('settings confirm table includes Plan Drift Guard row', () => { + const content = fs.readFileSync(SETTINGS_PATH, 'utf-8'); + assert.ok( + content.includes('Plan Drift Guard'), + 'settings.md confirm table must include a "Plan Drift Guard" row' + ); + }); + + // ── B1: CONFIGURATION.md ───────────────────────────────────────────────── + + test('CONFIGURATION.md documents plan_review.source_grounding', () => { + const content = fs.readFileSync(CONFIGURATION_PATH, 'utf-8'); + assert.ok( + content.includes('`plan_review.source_grounding`'), + 'CONFIGURATION.md must document plan_review.source_grounding' + ); + }); + + test('CONFIGURATION.md documents plan_review.source_grounding_authority', () => { + const content = fs.readFileSync(CONFIGURATION_PATH, 'utf-8'); + assert.ok( + content.includes('`plan_review.source_grounding_authority`'), + 'CONFIGURATION.md must document plan_review.source_grounding_authority' + ); + }); + + test('CONFIGURATION.md documents grep as default authority', () => { + const content = fs.readFileSync(CONFIGURATION_PATH, 'utf-8'); + assert.ok( + content.includes('`grep`') && content.includes('source_grounding_authority'), + 'CONFIGURATION.md must document grep as the default source_grounding_authority' + ); + }); + + test('CONFIGURATION.md lists all five authority enum values', () => { + const content = fs.readFileSync(CONFIGURATION_PATH, 'utf-8'); + const authorities = ['grep', 'intel', 'treesitter', 'lsp', 'scip']; + const missing = authorities.filter(a => !content.includes(a)); + assert.deepStrictEqual( + missing, + [], + `CONFIGURATION.md must list all authority values; missing: ${missing.join(', ')}` + ); + }); + + // ── B2: COMMANDS.md ─────────────────────────────────────────────────────── + + test('COMMANDS.md mentions intel api-surface', () => { + const content = fs.readFileSync(COMMANDS_PATH, 'utf-8'); + assert.ok( + content.includes('intel api-surface'), + 'COMMANDS.md must document the gsd-tools intel api-surface command' + ); + }); + + test('COMMANDS.md documents api-surface gating on intel.enabled', () => { + const content = fs.readFileSync(COMMANDS_PATH, 'utf-8'); + assert.ok( + content.includes('intel.enabled'), + 'COMMANDS.md intel api-surface section must mention the intel.enabled gate' + ); + }); + + test('COMMANDS.md mentions API-SURFACE.md output', () => { + const content = fs.readFileSync(COMMANDS_PATH, 'utf-8'); + assert.ok( + content.includes('API-SURFACE.md'), + 'COMMANDS.md must mention the API-SURFACE.md output file' + ); + }); + + // ── B3: USER-GUIDE.md ───────────────────────────────────────────────────── + + test('USER-GUIDE.md has Plan Drift Guard subsection', () => { + const content = fs.readFileSync(USER_GUIDE_PATH, 'utf-8'); + assert.ok( + content.includes('### Plan Drift Guard'), + 'USER-GUIDE.md must have a "### Plan Drift Guard" subsection' + ); + }); + + test('USER-GUIDE.md mentions needs-acknowledgement behavior', () => { + const content = fs.readFileSync(USER_GUIDE_PATH, 'utf-8'); + assert.ok( + content.includes('needs-acknowledgement'), + 'USER-GUIDE.md drift guard section must describe needs-acknowledgement behavior' + ); + }); + + test('USER-GUIDE.md explains drift guard works without intel', () => { + const content = fs.readFileSync(USER_GUIDE_PATH, 'utf-8'); + assert.ok( + content.includes('without intel') || content.includes('Works without intel'), + 'USER-GUIDE.md must explain that the drift guard works without intel' + ); + }); + + // ── B4: ARCHITECTURE.md ─────────────────────────────────────────────────── + + test('ARCHITECTURE.md links to ADR 22', () => { + const content = fs.readFileSync(ARCHITECTURE_PATH, 'utf-8'); + assert.ok( + content.includes('adr/22-plan-drift-guard.md') || content.includes('ADR 22'), + 'ARCHITECTURE.md must link to ADR 22 (adr/22-plan-drift-guard.md)' + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-2430-learnings-consumption.test.cjs — consolidation epic #1969 (B4 #1973) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-2430-learnings-consumption (consolidation epic #1969 B4 #1973)", () => { +'use strict'; + +// allow-test-rule: source-text-is-the-product (see #2430) +// Reads .md/.json/.yml product files whose deployed text IS what the +// runtime loads — testing text content tests the deployed contract. + +/** + * Tests for #2430 — LEARNINGS.md consumption loop. + * + * Part A: plan-phase.md cross-phase context load includes LEARNINGS.md + * Part B: transition.md graduation_scan step + graduation.md helper + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const WORKFLOWS_DIR = path.join(__dirname, '../gsd-core/workflows'); + +function readWorkflow(name) { + return fs.readFileSync(path.join(WORKFLOWS_DIR, name), 'utf-8'); +} + +describe('enh-2430 Part A — plan-phase LEARNINGS.md context load', () => { + let content; + + test('plan-phase.md includes LEARNINGS.md in cross-phase context load', () => { + content = readWorkflow('plan-phase.md'); + assert.ok( + content.includes('LEARNINGS.md files from the 3 most recent completed phases'), + 'plan-phase.md must mention LEARNINGS.md in cross-phase context block' + ); + }); + + test('plan-phase.md LEARNINGS load is inside the 1M context-window gate', () => { + content = content || readWorkflow('plan-phase.md'); + const windowBlock = content.match(/\$\{CONTEXT_WINDOW >= 500000[\s\S]*?` : ''\}/); + assert.ok(windowBlock, 'CONTEXT_WINDOW gate block must exist'); + assert.ok( + windowBlock[0].includes('LEARNINGS.md'), + 'LEARNINGS.md load must be inside the CONTEXT_WINDOW >= 500000 gate' + ); + }); + + test('plan-phase.md source attribution mentioned for LEARNINGS load', () => { + content = content || readWorkflow('plan-phase.md'); + assert.ok( + content.includes('[from Phase N LEARNINGS]') || content.includes('source attribution'), + 'plan-phase.md must document source attribution for loaded LEARNINGS.md content' + ); + }); + + test('plan-phase.md handles missing LEARNINGS.md gracefully (silent skip)', () => { + content = content || readWorkflow('plan-phase.md'); + assert.ok( + content.includes('skip silently if a phase has no LEARNINGS.md') || + content.includes('skip silently'), + 'plan-phase.md must document silent skip when LEARNINGS.md is absent' + ); + }); + + test('plan-phase.md LEARNINGS load includes Depends-on chain', () => { + content = content || readWorkflow('plan-phase.md'); + content.match(/Depends on.*?(\n.*?)+/); + assert.ok( + content.includes('LEARNINGS.md from any phases listed in'), + 'plan-phase.md must load LEARNINGS.md for Depends on chain phases' + ); + }); + + test('plan-phase.md specifies context budget limit for LEARNINGS', () => { + content = content || readWorkflow('plan-phase.md'); + assert.ok( + content.includes('15%') || content.includes('drop oldest'), + 'plan-phase.md must specify budget limit and truncation strategy for LEARNINGS' + ); + }); +}); + +describe('enh-2430 Part B — graduation_scan in transition.md', () => { + let content; + + test('transition.md contains graduation_scan step', () => { + content = readWorkflow('transition.md'); + assert.ok( + content.includes('graduation_scan'), + 'transition.md must contain graduation_scan step' + ); + }); + + test('graduation_scan is placed after evolve_project step', () => { + content = content || readWorkflow('transition.md'); + const evolvePos = content.indexOf('name="evolve_project"'); + const graduationPos = content.indexOf('name="graduation_scan"'); + assert.ok(evolvePos >= 0, 'evolve_project step must exist'); + assert.ok(graduationPos >= 0, 'graduation_scan step must exist'); + assert.ok( + graduationPos > evolvePos, + 'graduation_scan must appear after evolve_project in transition.md' + ); + }); + + test('graduation_scan is non-blocking (transition continues regardless)', () => { + content = content || readWorkflow('transition.md'); + const scanBlock = content.match(/name="graduation_scan"[\s\S]*?<\/step>/); + assert.ok(scanBlock, 'graduation_scan step must be parseable'); + assert.ok( + scanBlock[0].includes('non-blocking') || scanBlock[0].includes('always non-blocking'), + 'graduation_scan must be documented as non-blocking' + ); + }); + + test('graduation_scan delegates to graduation.md helper', () => { + content = content || readWorkflow('transition.md'); + assert.ok( + content.includes('graduation.md'), + 'graduation_scan must reference graduation.md helper workflow' + ); + }); +}); + +describe('enh-2430 Part B — graduation.md helper workflow', () => { + let content; + + test('graduation.md exists', () => { + content = readWorkflow('graduation.md'); + assert.ok(content.length > 0, 'graduation.md must exist and be non-empty'); + }); + + test('graduation.md documents features.graduation config flag', () => { + content = content || readWorkflow('graduation.md'); + assert.ok( + content.includes('features.graduation'), + 'graduation.md must document features.graduation config flag' + ); + }); + + test('graduation.md documents graduation_window config', () => { + content = content || readWorkflow('graduation.md'); + assert.ok( + content.includes('graduation_window'), + 'graduation.md must document features.graduation_window config' + ); + }); + + test('graduation.md documents graduation_threshold config', () => { + content = content || readWorkflow('graduation.md'); + assert.ok( + content.includes('graduation_threshold'), + 'graduation.md must document features.graduation_threshold config' + ); + }); + + test('graduation.md specifies HITL: Promote / Defer / Dismiss', () => { + content = content || readWorkflow('graduation.md'); + assert.ok(content.includes('Promote'), 'graduation.md must document Promote action'); + assert.ok(content.includes('Defer'), 'graduation.md must document Defer action'); + assert.ok(content.includes('Dismiss'), 'graduation.md must document Dismiss action'); + }); + + test('graduation.md specifies category→target routing', () => { + content = content || readWorkflow('graduation.md'); + assert.ok( + content.includes('PROJECT.md') && content.includes('PATTERNS.md'), + 'graduation.md must route categories to appropriate target files' + ); + }); + + test('graduation.md specifies graduation_backlog in STATE.md', () => { + content = content || readWorkflow('graduation.md'); + assert.ok( + content.includes('graduation_backlog'), + 'graduation.md must document STATE.md graduation_backlog for Defer/Dismiss' + ); + }); + + test('graduation.md skips items with graduated: annotation', () => { + content = content || readWorkflow('graduation.md'); + assert.ok( + content.includes('graduated:') || content.includes('Graduated:'), + 'graduation.md must skip already-graduated items' + ); + }); + + test('graduation.md has silent no-op for first phase / insufficient data', () => { + content = content || readWorkflow('graduation.md'); + assert.ok( + content.includes('no-op') || content.includes('silent'), + 'graduation.md must silently no-op when there is insufficient data' + ); + }); + + test('graduation.md specifies Defer-all shorthand (A key)', () => { + content = content || readWorkflow('graduation.md'); + assert.ok( + content.includes('Defer all') || content.includes('[Defer all]'), + 'graduation.md must document the Defer all shorthand for first-run batches' + ); + }); +}); + +describe('enh-2430 — extract-learnings.md graduated: field', () => { + test('extract-learnings.md documents optional graduated: annotation', () => { + const content = readWorkflow('extract-learnings.md'); + assert.ok( + content.includes('graduated:') || content.includes('Graduated:'), + 'extract-learnings.md must document optional graduated: field' + ); + }); + + test('extract-learnings.md clarifies graduated: is written only by graduation workflow', () => { + const content = readWorkflow('extract-learnings.md'); + assert.ok( + content.includes('graduation workflow') || content.includes('graduation.md'), + 'extract-learnings.md must clarify that graduated: is written only by graduation.md' + ); + }); +}); + +describe('enh-2430 — INVENTORY sync', () => { + test('INVENTORY.md lists graduation.md', () => { + const inventory = fs.readFileSync( + path.join(__dirname, '../docs/INVENTORY.md'), 'utf-8' + ); + assert.ok(inventory.includes('graduation.md'), 'INVENTORY.md must list graduation.md'); + }); + + test('INVENTORY-MANIFEST.json includes graduation.md', () => { + const manifest = JSON.parse( + fs.readFileSync(path.join(__dirname, '../docs/INVENTORY-MANIFEST.json'), 'utf-8') + ); + assert.ok( + manifest.families.workflows.includes('graduation.md'), + 'INVENTORY-MANIFEST.json must include graduation.md in workflows array' + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2492-context-coverage-gate.test.cjs — consolidation epic #1969 (B4 #1973) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2492-context-coverage-gate (consolidation epic #1969 B4 #1973)", () => { +// allow-test-rule: source-text-is-the-product (see #2492) +// Workflow .md / agent .md / command .md / reference .md files — their text +// IS what the runtime loads. Testing text content tests the deployed contract. +// Per CONTRIBUTING.md exception matrix. + +/** + * Bug #2492: Add gates to ensure discuss-phase decisions are translated to + * plans (plan-phase, BLOCKING) and verified against shipped artifacts + * (verify-phase, NON-BLOCKING). + * + * These workflow files are loaded as prompts by the corresponding subagents. + * The tests below verify that the prompt text contains the gate steps and + * the config-toggle skip clauses — losing them silently would regress the + * fix. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const PLAN_PHASE = path.join(__dirname, '..', 'gsd-core', 'workflows', 'plan-phase.md'); +const VERIFY_PHASE = path.join(__dirname, '..', 'gsd-core', 'workflows', 'verify-phase.md'); +const SCHEMA_MANIFEST_JSON = path.join(__dirname, '..', 'gsd-core', 'bin', 'shared', 'config-schema.manifest.json'); + +describe('plan-phase decision-coverage gate (#2492)', () => { + const md = fs.readFileSync(PLAN_PHASE, 'utf-8'); + + test('contains a Decision Coverage Gate step', () => { + assert.ok( + /Decision Coverage Gate/i.test(md), + 'plan-phase.md must define a Decision Coverage Gate step', + ); + }); + + test('invokes the check.decision-coverage-plan handler', () => { + assert.ok( + md.includes('check.decision-coverage-plan'), + 'plan-phase.md must call gsd-sdk query check.decision-coverage-plan', + ); + }); + + test('mentions workflow.context_coverage_gate skip clause', () => { + assert.ok( + md.includes('workflow.context_coverage_gate'), + 'plan-phase.md must reference workflow.context_coverage_gate to allow skipping', + ); + }); + + test('decision gate appears AFTER the existing Requirements Coverage Gate', () => { + // Anchored heading regexes — avoid prose-substring traps (review F8/F9). + const reqIdx = md.search(/^## 13[a-z]?\.\s+Requirements Coverage Gate/m); + const decIdx = md.search(/^## 13[a-z]?\.\s+Decision Coverage Gate/m); + assert.ok(reqIdx !== -1, 'Requirements Coverage Gate heading must exist as ## 13[a-z]?.'); + assert.ok(decIdx !== -1, 'Decision Coverage Gate heading must exist as ## 13[a-z]?.'); + assert.ok(decIdx > reqIdx, 'Decision gate must run after Requirements gate'); + }); + + test('decision gate appears BEFORE plans are committed', () => { + const decIdx = md.search(/^## 13[a-z]?\.\s+Decision Coverage Gate/m); + const commitIdx = md.search(/^## 13[a-z]?\.\s+Commit Plans/m); + assert.ok(decIdx !== -1, 'Decision Coverage Gate heading must exist as ## 13[a-z]?.'); + assert.ok(commitIdx !== -1, 'Commit Plans heading must exist as ## 13[a-z]?.'); + assert.ok(decIdx < commitIdx, 'Decision gate must run before commit so failures block the commit'); + }); + + test('plan-phase Decision Coverage Gate uses CONTEXT_PATH variable defined in INIT extraction (review F1)', () => { + // The CONTEXT_PATH bash variable is defined at Step 4 (`CONTEXT_PATH=$(_gsd_field "$INIT" context_path)`). + // The plan-phase gate snippet must reference the same casing — `${CONTEXT_PATH}` — not `${context_path}`, + // otherwise the BLOCKING gate is invoked with an empty path and silently skips. + const defIdx = md.indexOf('CONTEXT_PATH=$(_gsd_field "$INIT" context_path)'); + assert.ok(defIdx !== -1, 'CONTEXT_PATH must be defined from INIT JSON'); + + const gateIdx = md.indexOf('check.decision-coverage-plan'); + assert.ok(gateIdx !== -1, 'check.decision-coverage-plan invocation must exist'); + + // Slice the surrounding gate snippet (~600 chars) and verify variable casing matches the definition. + const snippet = md.slice(Math.max(0, gateIdx - 200), gateIdx + 400); + assert.ok( + snippet.includes('${CONTEXT_PATH}'), + 'Gate snippet must reference ${CONTEXT_PATH} (uppercase) to match the variable defined in Step 4', + ); + assert.ok( + !snippet.includes('${context_path}'), + 'Gate snippet must NOT reference ${context_path} (lowercase) — that name is undefined in shell scope', + ); + }); + + test('plan-phase blocking gate exits non-zero on failure (review F15)', () => { + // The gate is documented as BLOCKING. To actually block, the shell snippet must + // exit with non-zero status when `passed` is false. Without exit-1 the workflow + // continues silently past the failure. + const gateIdx = md.indexOf('check.decision-coverage-plan'); + assert.ok(gateIdx !== -1); + const snippet = md.slice(gateIdx, gateIdx + 800); + // Accept either an inline `|| exit 1` or a `|| { ...; exit 1; }` group. + const hasJqGuard = + /jq[^\r\n]*\.data\.passed\s*==\s*true/.test(snippet) || + /jq[^\r\n]*\(\.passed\s*\/\/\s*\.data\.passed\)\s*==\s*true/.test(snippet); + const hasExitOne = /\|\|\s*(?:exit\s+1|\{[\s\S]{0,200}?exit\s+1)/.test(snippet); + assert.ok( + hasJqGuard && hasExitOne, + 'plan-phase gate must guard with `jq -e .passed == true || exit 1` (or `|| { ...; exit 1; }`) to actually block', + ); + }); + + test('plan-phase gate accepts top-level .passed field from CLI output (#275)', () => { + const gateIdx = md.indexOf('check.decision-coverage-plan'); + assert.ok(gateIdx !== -1, 'check.decision-coverage-plan invocation must exist'); + const snippet = md.slice(gateIdx, gateIdx + 800); + assert.ok( + /\.(?:passed)\s*==\s*true\s*\|\|\s*\.data\.passed\s*==\s*true/.test(snippet) || + /\(\.passed\s*\/\/\s*\.data\.passed\)\s*==\s*true/.test(snippet), + 'plan-phase gate must explicitly check top-level .passed with a compatibility fallback to .data.passed', + ); + }); +}); + +describe('verify-phase decision-coverage gate (#2492)', () => { + const md = fs.readFileSync(VERIFY_PHASE, 'utf-8'); + + test('contains a verify_decisions step', () => { + assert.ok( + /verify_decisions/.test(md), + 'verify-phase.md must define a verify_decisions step', + ); + }); + + test('invokes the check.decision-coverage-verify handler', () => { + assert.ok( + md.includes('check.decision-coverage-verify'), + 'verify-phase.md must call gsd-sdk query check.decision-coverage-verify', + ); + }); + + test('declares the decision gate as non-blocking / warning only', () => { + const lower = md.toLowerCase(); + assert.ok( + lower.includes('non-blocking') || lower.includes('warning only') || lower.includes('not block'), + 'verify-phase.md must declare the decision gate is non-blocking', + ); + }); + + test('mentions workflow.context_coverage_gate skip clause', () => { + assert.ok( + md.includes('workflow.context_coverage_gate'), + 'verify-phase.md must reference workflow.context_coverage_gate to allow skipping', + ); + }); +}); + +describe('runtime wiring for #2492 gates', () => { + test('schema manifest includes context_coverage_gate', () => { + const manifest = JSON.parse(fs.readFileSync(SCHEMA_MANIFEST_JSON, 'utf-8')); + assert.ok( + manifest.validKeys.includes('workflow.context_coverage_gate'), + 'workflow.context_coverage_gate must be present in config-schema manifest', + ); + }); +}); + }); +} diff --git a/tests/plan-phase-ui-redirect.test.cjs b/tests/plan-phase-ui-redirect.test.cjs index 6a00c61c0..1d0193c1e 100644 --- a/tests/plan-phase-ui-redirect.test.cjs +++ b/tests/plan-phase-ui-redirect.test.cjs @@ -306,3 +306,75 @@ function extractSection56(workflowPath) { assert.ok(end !== -1, '## 6. heading must follow §5.6 in plan-phase.md (§5.7 was removed; schema-gate is now a capability)'); return content.slice(start, end); } + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3072-optional-sketch-findings-guard.test.cjs — consolidation epic #1969 (B4 #1973) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3072-optional-sketch-findings-guard (consolidation epic #1969 B4 #1973)", () => { +'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 ROOT = path.join(__dirname, '..', 'gsd-core', 'workflows'); + +function read(rel) { + return fs.readFileSync(path.join(ROOT, rel), 'utf8'); +} + +function extractFindingsProbesFromBashBlocks(markdown) { + const probes = []; + const fenceRe = /```bash\r?\n([\s\S]*?)```/g; + let fenceMatch; + + while ((fenceMatch = fenceRe.exec(markdown)) !== null) { + const block = fenceMatch[1]; + const baseLine = markdown.slice(0, fenceMatch.index).split(/\r?\n/).length; + const lines = block.split(/\r?\n/); + + lines.forEach((line, idx) => { + if (!line.includes('.claude/skills/')) return; + const kind = line.includes('sketch-findings-*/SKILL.md') + ? 'sketch' + : line.includes('spike-findings-*/SKILL.md') + ? 'spike' + : null; + if (!kind) return; + + probes.push({ + lineNumber: baseLine + idx, + commandText: line.trim(), + kind, + hasNonFatalGuard: /\|\|\s*true/.test(line), + }); + }); + } + + return probes; +} + +describe('bug #3072: optional sketch/spike findings probes are non-fatal', () => { + test('all sketch/spike findings SKILL.md ls probes include || true', () => { + const files = ['ui-phase.md', 'plan-phase.md', 'discuss-phase.md', 'new-project.md']; + const offenders = []; + + for (const file of files) { + const content = read(file); + const probes = extractFindingsProbesFromBashBlocks(content); + for (const probe of probes) { + if (!probe.hasNonFatalGuard) { + offenders.push(`${file}:${probe.lineNumber} ${probe.commandText}`); + } + } + } + + assert.deepStrictEqual(offenders, [], `missing non-fatal guard on optional findings probe:\n${offenders.join('\n')}`); + }); +}); + }); +} diff --git a/tests/plan-review-convergence.test.cjs b/tests/plan-review-convergence.test.cjs index 1260a3016..812633b81 100644 --- a/tests/plan-review-convergence.test.cjs +++ b/tests/plan-review-convergence.test.cjs @@ -908,3 +908,220 @@ describe('plan-review-convergence workflow: inline plan-phase dispatch (#936)', ); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-936-no-nested-spawner-wrap.test.cjs — consolidation epic #1969 (B4 #1973) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-936-no-nested-spawner-wrap (consolidation epic #1969 B4 #1973)", () => { +'use strict'; +/** + * Structural guard — bug(#936): plan-review-convergence wrapped gsd-plan-phase + * in Agent() at TWO sites (initial planning + replan). On Claude Code, a depth-1 + * Agent has no Agent tool, so plan-phase cannot spawn gsd-planner / gsd-plan-checker + * → the replan loop never works when HIGHs are found. + * + * Fix: run plan-phase INLINE (bare Skill()) from the convergence orchestrator, + * which runs at depth 0 and has Agent available — exactly how autonomous.md, + * manager.md, and discuss-phase-assumptions.md already chain plan-phase. + * + * This guard dynamically derives the set of "spawner" workflows (those containing + * `subagent_type=`) and asserts that NO workflow wraps a spawner inside Agent() + * UNLESS the wrapping block includes a RUNTIME != claude carve-out (the #853 + * pattern already applied to autonomous.md / manager.md). + */ + +// allow-test-rule: source-text-is-the-product (see #936) +// The workflow markdown IS the runtime instruction — static guards over +// workflow text are the canonical regression-test mechanism (per CONTRIBUTING +// exception matrix and tests/bug-853-bg-dispatch-runtime-gating.test.cjs). + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows'); + +// ── 1. Derive spawner skill names dynamically ────────────────────────────── +// A "spawner" workflow is one that contains `subagent_type=` — it NEEDS the +// Agent tool to run and therefore cannot safely be wrapped in another Agent() +// on Claude Code (where depth-1 agents have no Agent tool). + +// Recursively collect all *.md files under WORKFLOWS_DIR (covers nested fragments +// like discuss-phase/modes/*.md and execute-phase/steps/*.md). +function collectWorkflowFiles(dir) { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + const results = []; + for (const e of entries) { + const fullPath = path.join(dir, e.name); + if (e.isDirectory()) { + results.push(...collectWorkflowFiles(fullPath)); + } else if (e.name.endsWith('.md')) { + results.push({ + name: path.relative(WORKFLOWS_DIR, fullPath), + path: fullPath, + content: fs.readFileSync(fullPath, 'utf8'), + }); + } + } + return results; +} + +const allWorkflowFiles = collectWorkflowFiles(WORKFLOWS_DIR); + +// Map: base-slug → workflow filename (e.g. "plan-phase" → "plan-phase.md") +// Skill() calls use the "gsd-" convention in all workflow files. +// We build BOTH the bare slug set and the gsd-prefixed skill-name set. +const SPAWNER_BASE_SLUGS = new Set( + allWorkflowFiles + .filter((w) => w.content.includes('subagent_type=')) + .map((w) => w.name.replace(/\.md$/, '')) +); + +// Skill invocations use "gsd-" (e.g. gsd-plan-phase, gsd-execute-phase). +// Build the regex from the prefixed names so it actually matches what workflows write. +const SPAWNER_GSD_NAMES = new Set([...SPAWNER_BASE_SLUGS].map((s) => `gsd-${s}`)); + +// Build a regex that matches Skill(skill='gsd-') or Skill(skill="gsd-") +const spawnerPattern = new RegExp( + `Skill\\(\\s*skill=['"](?:${[...SPAWNER_GSD_NAMES].join('|')})['"]`, + 's' +); + +// ── 2. Helper: extract Agent() blocks from a workflow ───────────────────── +// Each block starts at "Agent(" and ends at the balancing ")". We collect +// the text of each such block together with the surrounding context (a 400 +// char window before the block) so we can check for RUNTIME carve-outs. + +function extractAgentBlocks(content) { + const blocks = []; + let pos = 0; + while (pos < content.length) { + const start = content.indexOf('Agent(', pos); + if (start === -1) break; + // Walk forward to find the balancing closing paren + let depth = 0; + let i = start + 'Agent('.length - 1; // at the '(' + for (; i < content.length; i++) { + if (content[i] === '(') depth++; + else if (content[i] === ')') { + depth--; + if (depth === 0) break; + } + } + const end = i + 1; + const blockText = content.slice(start, end); + // Capture context: 400 chars before the block (for RUNTIME gate detection) + const contextBefore = content.slice(Math.max(0, start - 400), start); + blocks.push({ start, end, blockText, contextBefore }); + pos = end; + } + return blocks; +} + +// ── 3. Helper: does a block have a RUNTIME != claude carve-out nearby? ──── +// The #853 pattern looks like: "RUNTIME is `claude`" in a preceding condition +// that switches to inline Skill() instead of the Agent() block. A block is +// considered guarded when the 400-char context window before it (or the block +// body itself for block-internal guards) contains any of these markers. + +function hasRuntimeCarveout(block) { + const haystack = block.contextBefore + block.blockText; + return ( + /RUNTIME[^`\n]{0,30}(?:!=|≠|is not|!==)\s*[`'"]?claude/i.test(haystack) || + /RUNTIME[^`\n]{0,30}claude[^`\n]{0,30}(?:inline|not.*Agent|do NOT)/i.test(haystack) || + /If `RUNTIME` is `claude`/i.test(haystack) || + /On Claude Code.*inline/is.test(haystack) + ); +} + +// ── 4. The guard: scan every workflow for unguarded Agent→spawner wraps ─── + +describe('bug-936 — no workflow wraps a spawner skill inside Agent() without a RUNTIME carve-out', () => { + test('spawner set is non-empty (self-check: subagent_type= grep must find files)', () => { + assert.ok(SPAWNER_BASE_SLUGS.size > 0, `No spawner workflows found in ${WORKFLOWS_DIR} — SPAWNER_BASE_SLUGS derivation is broken`); + // plan-phase must be a spawner (base slug) + assert.ok(SPAWNER_BASE_SLUGS.has('plan-phase'), 'plan-phase.md must be in the spawner set (contains subagent_type=)'); + // gsd-plan-phase must be in the prefixed set used by the regex + assert.ok(SPAWNER_GSD_NAMES.has('gsd-plan-phase'), 'gsd-plan-phase must be in SPAWNER_GSD_NAMES — the prefixed form used in Skill() calls'); + }); + + for (const wf of allWorkflowFiles) { + // Only scan files that have at least one Agent( call + if (!wf.content.includes('Agent(')) continue; + + test(`${wf.name}: no Agent() block wraps a spawner Skill without a RUNTIME carve-out`, () => { + const blocks = extractAgentBlocks(wf.content); + const violations = blocks.filter((b) => { + const wrapsSpawner = spawnerPattern.test(b.blockText); + if (!wrapsSpawner) return false; + return !hasRuntimeCarveout(b); + }); + + assert.deepStrictEqual( + violations.map((v) => v.blockText.slice(0, 120).replace(/\n/g, '\\n')), + [], + `${wf.name} wraps a spawner Skill inside Agent() without a RUNTIME != claude carve-out.\n` + + `Fix: run the spawner Skill inline (bare Skill() call at depth 0) OR add a RUNTIME gate.\n` + + `See: bug #936, tests/bug-853-bg-dispatch-runtime-gating.test.cjs for the guarded pattern.` + ); + }); + } +}); + +// ── 5. Focused regression: plan-review-convergence never wraps plan-phase ─ + +describe('bug-936 — plan-review-convergence runs plan-phase inline, not inside Agent()', () => { + const CONVERGENCE = fs.readFileSync( + path.join(WORKFLOWS_DIR, 'plan-review-convergence.md'), + 'utf8' + ); + + test('plan-review-convergence does NOT wrap gsd-plan-phase inside Agent()', () => { + // The anti-pattern: Agent( block whose body contains Skill(skill='gsd-plan-phase') + const blocks = extractAgentBlocks(CONVERGENCE); + const wrapping = blocks.filter((b) => + /Skill\(\s*skill=['"]gsd-plan-phase['"]/.test(b.blockText) && + !hasRuntimeCarveout(b) + ); + assert.deepStrictEqual( + wrapping.map((v) => v.blockText.slice(0, 120).replace(/\n/g, '\\n')), + [], + 'plan-review-convergence must NOT wrap gsd-plan-phase inside Agent(). ' + + 'Run it inline (bare Skill() at depth 0) so it can spawn gsd-planner/gsd-plan-checker. ' + + 'See: bug #936' + ); + }); + + test('plan-review-convergence calls gsd-plan-phase inline (bare Skill call outside Agent block)', () => { + // After the fix: at least one bare Skill(skill="gsd-plan-phase") must appear + // outside any Agent( block — that is the inline call from the depth-0 orchestrator. + const blocks = extractAgentBlocks(CONVERGENCE); + // Remove all Agent block ranges from the text + let masked = CONVERGENCE; + // Work from end to start so offsets stay valid + const sorted = [...blocks].sort((a, b) => b.start - a.start); + for (const b of sorted) { + masked = masked.slice(0, b.start) + ' '.repeat(b.end - b.start) + masked.slice(b.end); + } + const hasInlineCall = /Skill\(\s*skill=["']gsd-plan-phase["']/.test(masked); + assert.ok( + hasInlineCall, + 'plan-review-convergence must contain at least one bare Skill(skill="gsd-plan-phase") ' + + 'outside any Agent() block — this is the inline call that lets plan-phase spawn its sub-agents. ' + + 'See: bug #936' + ); + }); + + test('plan-review-convergence still wraps gsd-review inside Agent() (leaf — isolation is correct)', () => { + // gsd-review is a leaf (shells out via Bash, no subagent_type) so the Agent wrap is fine and intentional. + const blocks = extractAgentBlocks(CONVERGENCE); + const reviewWrap = blocks.some((b) => /Skill\(\s*skill=['"]gsd-review['"]/.test(b.blockText)); + assert.ok(reviewWrap, 'gsd-review must still be wrapped in Agent() — it is a Bash leaf and isolation is intentional'); + }); +}); + }); +} diff --git a/tests/planner-decomposition.test.cjs b/tests/planner-decomposition.test.cjs index a47483166..de42eb391 100644 --- a/tests/planner-decomposition.test.cjs +++ b/tests/planner-decomposition.test.cjs @@ -138,3 +138,105 @@ describe('reference files contain key content from original mode sections', () = assert.ok(hasReviewsContent, 'planner-reviews.md must contain reviews mode content'); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3320-planner-deep-work-rules.test.cjs — consolidation epic #1969 (B4 #1973) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3320-planner-deep-work-rules (consolidation epic #1969 B4 #1973)", () => { +'use strict'; + +// allow-test-rule: source-text-is-product [#3320] +// The bug is a contradiction in prompt/workflow source text. These assertions +// intentionally pin the contract words that planner agents consume. + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.join(__dirname, '..'); +const PLANNER_AGENT = path.join(ROOT, 'agents', 'gsd-planner.md'); +const PLAN_PHASE_WORKFLOW = path.join(ROOT, 'gsd-core', 'workflows', 'plan-phase.md'); + +function read(relativePath) { + return fs.readFileSync(path.join(ROOT, relativePath), 'utf8'); +} + +function extractDeepWorkRules() { + const workflow = fs.readFileSync(PLAN_PHASE_WORKFLOW, 'utf8'); + const match = workflow.match(/[\s\S]*?<\/deep_work_rules>/); + assert.ok(match, 'plan-phase.md must contain a deep_work_rules block'); + return match[0]; +} + +describe('bug #3320 planner action contract', () => { + test('planner agent explicitly keeps implementation code out of action blocks', () => { + const planner = fs.readFileSync(PLANNER_AGENT, 'utf8'); + + assert.match( + planner, + /NEVER place fenced code blocks \(```\) inside ``/, + 'gsd-planner.md must explicitly forbid fenced implementation code in ' + ); + assert.match( + planner, + /Code excerpts belong in `` source files or referenced context/, + 'gsd-planner.md must route code excerpts to context/read-first material' + ); + }); + + test('plan-phase deep_work_rules no longer requires self-sufficient code dumps', () => { + const deepWorkRules = extractDeepWorkRules(); + + assert.doesNotMatch( + deepWorkRules, + /copy them into the action verbatim/, + 'deep_work_rules must not tell planners to copy source material verbatim into ' + ); + assert.doesNotMatch( + deepWorkRules, + /complete the task from the action text alone/, + 'deep_work_rules must not make self-sufficient without read_first/context' + ); + assert.match( + deepWorkRules, + /Do not include full file contents, fenced code blocks, or complete implementations in ``/, + 'deep_work_rules must explicitly bound concrete values to avoid code dumping' + ); + }); + + test('plan-phase acceptance criteria allow behavior and test assertions', () => { + const deepWorkRules = extractDeepWorkRules(); + + assert.match( + deepWorkRules, + /behavior assertion/, + 'acceptance criteria must allow behavior assertions, not just grep checks' + ); + assert.match( + deepWorkRules, + /test command/, + 'acceptance criteria must allow test-command assertions' + ); + }); + + test('quality gate matches the reconciled planner contract', () => { + const workflow = read('gsd-core/workflows/plan-phase.md'); + + assert.match( + workflow, + /Every task has `` with behavior, test-command, CLI, or source assertions/, + 'quality gate must not narrow acceptance criteria back to grep-only checks' + ); + assert.match( + workflow, + /Every `` contains concrete identifiers without fenced code blocks or full implementations/, + 'quality gate must enforce concrete prose without implementation dumps' + ); + }); +}); + }); +} diff --git a/tests/planner-language-regression.test.cjs b/tests/planner-language-regression.test.cjs index 0c65871c1..6f33ca3c9 100644 --- a/tests/planner-language-regression.test.cjs +++ b/tests/planner-language-regression.test.cjs @@ -336,3 +336,156 @@ describe('gsd-plan-checker.md — scope reduction detection includes time/comple ); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3805-fast-md-log-to-state-schema.test.cjs — consolidation epic #1969 (B4 #1973) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3805-fast-md-log-to-state-schema (consolidation epic #1969 B4 #1973)", () => { +'use strict'; + +// allow-test-rule: source-text-is-the-product (see #3805) +// Reads gsd-core/workflows/fast.md whose deployed text IS the product — +// the workflow markdown is executed verbatim by LLM runtimes. + +/** + * #3805 — fast.md log_to_state appends a schema-blind 4-column row to the + * 5-column "Quick Tasks Completed" table created by quick.md Step 7. + * + * quick.md Step 7 creates the table with 5 columns: + * | # | Description | Date | Commit | Directory | + * + * Before this fix, fast.md's log_to_state step appended a hardcoded 4-cell + * row unconditionally: + * echo "| $(date +%Y-%m-%d) | fast | $TASK | ✅ |" >> .planning/STATE.md + * + * This produces malformed Markdown when the existing table has a different + * column count. + * + * Covers: + * - fast.md does NOT contain the hardcoded 4-cell echo template + * - fast.md log_to_state step reads/introspects the existing table header + * before appending (schema-aware insertion) + * - fast.md log_to_state step matches the 5-column schema from quick.md + * Step 7 when that table is present + * - fast.md log_to_state step skips (does not corrupt) the STATE.md write + * when the table schema is unrecognized + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const REPO_ROOT = path.join(__dirname, '..'); +const FAST_MD_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'fast.md'); + +// The 5-column schema defined in quick.md Step 7 (non-validate mode). +// Column count is 5: # | Description | Date | Commit | Directory +// Named constant for traceability — mirrors quick.md Step 7's table header. +const QUICK_MD_STEP7_COL_COUNT = 5; +const QUICK_MD_STEP7_COLUMNS = ['#', 'Description', 'Date', 'Commit', 'Directory']; + +describe('bug #3805: fast.md log_to_state must be schema-aware', () => { + let fastMdContent; + + test('fast.md workflow file exists and is readable', () => { + assert.ok(fs.existsSync(FAST_MD_PATH), `fast.md not found at ${FAST_MD_PATH}`); + fastMdContent = fs.readFileSync(FAST_MD_PATH, 'utf-8'); + }); + + test('fast.md log_to_state step does NOT hardcode a 4-cell row template', () => { + // The old broken template: | date | fast | task | ✅ | + // This regex matches the exact hardcoded pattern that ignores table schema. + // A 4-cell row has exactly 4 pipe-delimited fields plus the surrounding pipes. + const hardcoded4CellPattern = /echo\s+["'][|][^|]*[|][^|]*[|][^|]*[|][^|]*[|]\s*["']/; + const match = fastMdContent.match(hardcoded4CellPattern); + assert.ok( + !match, + [ + 'fast.md log_to_state still contains the hardcoded 4-cell row template:', + ` "${match?.[0]}"`, + 'This appends a malformed row to the 5-column Quick Tasks Completed table', + 'created by quick.md Step 7.', + `Expected table schema (${QUICK_MD_STEP7_COL_COUNT} cols): | ${QUICK_MD_STEP7_COLUMNS.join(' | ')} |`, + ].join('\n') + ); + }); + + test('fast.md log_to_state step reads the existing table header (schema introspection)', () => { + // The fix must inspect the existing STATE.md table header before appending. + // Acceptable signals: reading STATE.md content, grepping for the header line, + // or parsing columns from the header row. + const hasHeaderRead = + // Reads STATE.md to inspect it (awk/sed/grep on the file for header detection) + /grep.*Quick Tasks Completed.*STATE\.md/.test(fastMdContent) || + /awk.*Quick Tasks Completed/.test(fastMdContent) || + /sed.*Quick Tasks Completed/.test(fastMdContent) || + // Reads the header line explicitly (head -n, sed -n, awk NR==) + /head\s+-n/.test(fastMdContent) && /STATE\.md/.test(fastMdContent) || + // Counts pipe separators / columns from existing header + /col.*count|column.*count|count.*col|NF|awk.*\|/.test(fastMdContent) || + // References schema detection in prose + /schema|header|column\s+count|existing.*table/.test(fastMdContent); + + assert.ok( + hasHeaderRead, + [ + 'fast.md log_to_state step does not appear to introspect the existing table schema.', + 'The step must read the STATE.md table header to detect column count before appending.', + 'quick.md Step 7 uses schema-aware matching — fast.md must follow the same discipline.', + ].join('\n') + ); + }); + + test('fast.md log_to_state step references the 5-column quick.md schema', () => { + // The fix must handle the 5-col schema: | # | Description | Date | Commit | Directory | + // Test that all 5 column names appear in the log_to_state step's context. + // Extract the log_to_state step content to scope the check. + const logToStateMatch = fastMdContent.match(/([\s\S]*?)<\/step>/); + assert.ok(logToStateMatch, 'fast.md must contain a element'); + + const stepContent = logToStateMatch[1]; + + // All 5 column names from quick.md Step 7 must be referenced in the step. + for (const col of QUICK_MD_STEP7_COLUMNS) { + assert.ok( + stepContent.toLowerCase().includes(col.toLowerCase()), + [ + `fast.md log_to_state step does not reference column "${col}"`, + `Expected all 5 columns from quick.md Step 7: ${QUICK_MD_STEP7_COLUMNS.join(', ')}`, + `(${QUICK_MD_STEP7_COL_COUNT}-column schema)`, + ].join('\n') + ); + } + }); + + test('fast.md log_to_state step skips STATE.md write on unrecognized schema', () => { + // The fix must not blindly append when the table schema is unknown. + // Check for a guard that skips or logs rather than corrupting the file. + const logToStateMatch = fastMdContent.match(/([\s\S]*?)<\/step>/); + assert.ok(logToStateMatch, 'fast.md must contain a element'); + + const stepContent = logToStateMatch[1]; + + // Must have a skip/guard path for unrecognized schemas + const hasSkipGuard = + /skip/i.test(stepContent) || + /unrecognized|unknown|mismatch/i.test(stepContent) || + /else\b/.test(stepContent) || + /warn|log/i.test(stepContent); + + assert.ok( + hasSkipGuard, + [ + 'fast.md log_to_state step does not appear to guard against unrecognized table schemas.', + 'When the existing STATE.md table does not match an expected schema,', + 'the step must skip the write (with a brief log) rather than append a malformed row.', + ].join('\n') + ); + }); +}); + }); +} diff --git a/tests/progress-forensic.test.cjs b/tests/progress-forensic.test.cjs index 002a2d399..bdbb9b534 100644 --- a/tests/progress-forensic.test.cjs +++ b/tests/progress-forensic.test.cjs @@ -284,3 +284,166 @@ describe('#3418: /gsd-progress flag routing prompt contract', () => { }); }); } + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2912-progress-context-authority.test.cjs — consolidation epic #1969 (B4 #1973) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2912-progress-context-authority (consolidation epic #1969 B4 #1973)", () => { +/** + * Tests for issue #2912 — /gsd-progress can use stale CLAUDE.md project block + * instead of GSD tracking files as authoritative source. + * + * Fix: the `report` step in gsd-core/workflows/progress.md must contain + * an explicit "context authority" directive establishing PROJECT.md, STATE.md, + * and ROADMAP.md as the authoritative sources for the progress report, and + * forbidding the use of CLAUDE.md `## Project` blocks as a source for any + * report field. + * + * These tests parse the workflow markdown structurally (locate the + * ... block, then locate the blockquote-style + * directive inside it). They do NOT use `.includes()` over the whole file. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const WORKFLOW_PATH = path.join( + __dirname, + '..', + 'gsd-core', + 'workflows', + 'progress.md' +); + +/** Extract the body of a ... block by parsing tags. */ +function extractStep(workflow, stepName) { + const openTag = ``; + const start = workflow.indexOf(openTag); + if (start === -1) return null; + const bodyStart = start + openTag.length; + // Find the matching — workflow steps in this file do not nest. + const end = workflow.indexOf('', bodyStart); + if (end === -1) return null; + return workflow.slice(bodyStart, end); +} + +/** + * Extract contiguous markdown blockquote blocks from a chunk of markdown. + * A blockquote is a run of consecutive lines starting with '>' (after any + * leading whitespace). Returns the joined text of each blockquote with the + * leading '>' markers stripped. + */ +function extractBlockquotes(md) { + const lines = md.split(/\r?\n/); + const blocks = []; + let current = null; + for (const line of lines) { + const m = line.match(/^\s*>\s?(.*)$/); + if (m) { + if (current === null) current = []; + current.push(m[1]); + } else { + if (current !== null) { + blocks.push(current.join('\n')); + current = null; + } + } + } + if (current !== null) blocks.push(current.join('\n')); + return blocks; +} + +describe('#2912: progress report step has explicit context-authority directive', () => { + test('progress.md workflow file exists and is readable', () => { + const stat = fs.statSync(WORKFLOW_PATH); + assert.ok(stat.isFile(), 'workflow file should exist'); + }); + + test('progress.md has a section', () => { + const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf8'); + const reportStep = extractStep(workflow, 'report'); + assert.ok(reportStep, 'workflow should contain a report step'); + assert.ok(reportStep.length > 0, 'report step body should not be empty'); + }); + + test('report step contains a blockquote directive about context authority', () => { + const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf8'); + const reportStep = extractStep(workflow, 'report'); + assert.ok(reportStep, 'report step must be present'); + + const blockquotes = extractBlockquotes(reportStep); + assert.ok( + blockquotes.length > 0, + 'report step should contain at least one blockquote (the context-authority directive)' + ); + + const authorityBlock = blockquotes.find((b) => /context\s+authority/i.test(b)); + assert.ok( + authorityBlock, + 'report step should contain a blockquote whose text includes "Context authority"' + ); + }); + + test('context-authority directive names PROJECT.md, STATE.md, and ROADMAP.md as authoritative', () => { + const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf8'); + const reportStep = extractStep(workflow, 'report'); + assert.ok(reportStep, 'report step must exist'); + const blockquotes = extractBlockquotes(reportStep); + const authorityBlock = blockquotes.find((b) => /context\s+authority/i.test(b)); + assert.ok(authorityBlock, 'authority blockquote must exist'); + + assert.match( + authorityBlock, + /PROJECT\.md/, + 'directive should name PROJECT.md as authoritative' + ); + assert.match( + authorityBlock, + /STATE\.md/, + 'directive should name STATE.md as authoritative' + ); + assert.match( + authorityBlock, + /ROADMAP\.md/, + 'directive should name ROADMAP.md as authoritative' + ); + assert.match( + authorityBlock, + /authoritative/i, + 'directive should describe these files as authoritative' + ); + }); + + test('context-authority directive forbids using CLAUDE.md project block as a source', () => { + const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf8'); + const reportStep = extractStep(workflow, 'report'); + assert.ok(reportStep, 'report step must exist'); + const blockquotes = extractBlockquotes(reportStep); + const authorityBlock = blockquotes.find((b) => /context\s+authority/i.test(b)); + assert.ok(authorityBlock, 'authority blockquote must exist'); + + assert.match( + authorityBlock, + /CLAUDE\.md/, + 'directive should explicitly mention CLAUDE.md' + ); + // Must explicitly forbid CLAUDE.md as a source — look for a NOT/do not directive + // co-located with the CLAUDE.md mention. + assert.match( + authorityBlock, + /(do\s+NOT|do\s+not|must\s+NOT|must\s+not|never)/i, + 'directive should contain an explicit prohibition (do NOT / must not / never)' + ); + assert.match( + authorityBlock, + /## Project/, + 'directive should call out the CLAUDE.md "## Project" block specifically' + ); + }); +}); + }); +} diff --git a/tests/quick-commit-boundary.test.cjs b/tests/quick-commit-boundary.test.cjs index fc6d691da..54e1f4857 100644 --- a/tests/quick-commit-boundary.test.cjs +++ b/tests/quick-commit-boundary.test.cjs @@ -51,3 +51,218 @@ describe('quick workflow commit boundary (#1503)', () => { ); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2432-quick-plan-predispatch-commit.test.cjs — consolidation epic #1969 (B4 #1973) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2432-quick-plan-predispatch-commit (consolidation epic #1969 B4 #1973)", () => { +/** + * Bug #2432: quick.md PLAN.md timing — worktree executor can't read PLAN.md + * + * The orchestrator must commit PLAN.md to the base branch BEFORE spawning the + * worktree-isolated executor. Without this, the executor's first Read resolves + * to a main-repo absolute path (not a worktree path), which primes CC's path + * cache and causes subsequent Edit/Write calls to silently target the main repo + * instead of the worktree (CC issue #36182 amplifier). + * + * Fix: Step 5.6 commits PLAN.md pre-dispatch when USE_WORKTREES is active. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const QUICK_MD = path.join(__dirname, '..', 'gsd-core', 'workflows', 'quick.md'); + +describe('quick.md pre-dispatch PLAN.md commit (#2432)', () => { + let content; + + test('quick.md exists', () => { + assert.ok(fs.existsSync(QUICK_MD), 'gsd-core/workflows/quick.md must exist'); + content = fs.readFileSync(QUICK_MD, 'utf-8'); + }); + + test('Step 5.6 exists between Step 5.5 and Step 6', () => { + const step55 = content.indexOf('Step 5.5'); + const step56 = content.indexOf('Step 5.6'); + const step6 = content.indexOf('Step 6:'); + assert.ok(step55 !== -1, 'Step 5.5 must exist'); + assert.ok(step56 !== -1, 'Step 5.6 must exist'); + assert.ok(step6 !== -1, 'Step 6 must exist'); + assert.ok(step56 > step55, 'Step 5.6 must appear after Step 5.5'); + assert.ok(step56 < step6, 'Step 5.6 must appear before Step 6'); + }); + + test('Step 5.6 is gated on USE_WORKTREES', () => { + const step56Start = content.indexOf('Step 5.6'); + const step6Start = content.indexOf('Step 6:', step56Start); + const step56Block = content.slice(step56Start, step6Start); + assert.ok( + step56Block.includes('USE_WORKTREES'), + 'Step 5.6 must be gated on USE_WORKTREES — only commit pre-dispatch in worktree mode' + ); + }); + + test('Step 5.6 is gated on commit_docs', () => { + const step56Start = content.indexOf('Step 5.6'); + const step6Start = content.indexOf('Step 6:', step56Start); + const step56Block = content.slice(step56Start, step6Start); + assert.ok( + step56Block.includes('commit_docs'), + 'Step 5.6 must respect commit_docs config — skip pre-dispatch commit when commit_docs is false' + ); + }); + + test('Step 5.6 stages and commits PLAN.md', () => { + const step56Start = content.indexOf('Step 5.6'); + const step6Start = content.indexOf('Step 6:', step56Start); + const step56Block = content.slice(step56Start, step6Start); + assert.ok( + step56Block.includes('PLAN.md'), + 'Step 5.6 must reference PLAN.md in the pre-dispatch commit' + ); + assert.ok( + step56Block.includes('git add') || step56Block.includes('git commit'), + 'Step 5.6 must include git add/commit to stage and commit PLAN.md' + ); + }); + + test('Step 5.6 uses --no-verify to avoid hook interference', () => { + const step56Start = content.indexOf('Step 5.6'); + const step6Start = content.indexOf('Step 6:', step56Start); + const step56Block = content.slice(step56Start, step6Start); + assert.ok( + step56Block.includes('--no-verify'), + 'Step 5.6 pre-dispatch commit must use --no-verify to avoid hook interference' + ); + }); + + test('executor prompt references PLAN.md via relative (worktree-rooted) path', () => { + // QUICK_DIR is always set to ".planning/quick/..." (relative) so ${QUICK_DIR}/...PLAN.md + // resolves relative to the worktree root, not the main repo absolute path. + // Verify the executor prompt uses QUICK_DIR variable (not a hardcoded absolute path). + const executorTask = content.indexOf('subagent_type="gsd-executor"'); + assert.ok(executorTask !== -1, 'executor Task() spawn must exist'); + // Find the files_to_read block near the executor spawn + const filesBlock = content.lastIndexOf('', executorTask); + const filesBlockEnd = content.indexOf('', filesBlock); + const filesContent = content.slice(filesBlock, filesBlockEnd); + assert.ok( + filesContent.includes('QUICK_DIR') || filesContent.includes('.planning/quick'), + 'executor files_to_read must reference PLAN.md via relative QUICK_DIR path' + ); + assert.ok( + !filesContent.match(/\/home\/|\/Users\/|\/root\//), + 'executor files_to_read must NOT contain hardcoded absolute paths' + ); + }); + + test('#1265 records both parent base and plan commit for worktree dispatch', () => { + const step56Start = content.indexOf('Step 5.6'); + const step6Start = content.indexOf('Step 6:', step56Start); + const step56Block = content.slice(step56Start, step6Start); + const step6Block = content.slice(step6Start, content.indexOf('After executor returns:', step6Start)); + + assert.ok( + step56Block.includes('QUICK_PLAN_PARENT'), + 'quick worktree mode must record the pre-dispatch parent SHA before committing PLAN.md (#1265)' + ); + assert.ok( + step56Block.includes('QUICK_PLAN_COMMIT'), + 'quick worktree mode must record the PLAN.md commit SHA after the pre-dispatch commit (#1265)' + ); + assert.ok( + step6Block.includes('EXPECTED_BASE_ALTERNATE'), + 'executor guard embed must carry the alternate accepted base for parent-vs-plan worktree forks (#1265)' + ); + }); + + test('#1265 executor materializes PLAN.md from the plan commit when worktree starts at parent', () => { + const executorTask = content.indexOf('subagent_type="gsd-executor"'); + assert.ok(executorTask !== -1, 'executor Task() spawn must exist'); + const promptStart = content.lastIndexOf('prompt="', executorTask); + const promptEnd = content.indexOf('subagent_type="gsd-executor"', promptStart); + const executorPrompt = content.slice(promptStart, promptEnd); + + assert.ok( + executorPrompt.includes('git show') && executorPrompt.includes('QUICK_PLAN_COMMIT'), + 'executor prompt must materialize PLAN.md from git show : when absent (#1265)' + ); + assert.ok( + executorPrompt.indexOf('git show') < executorPrompt.indexOf(''), + 'PLAN.md materialization must be a first action before files_to_read can prime paths (#1265)' + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2523-quick-deferred-items.test.cjs — consolidation epic #1969 (B4 #1973) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2523-quick-deferred-items (consolidation epic #1969 B4 #1973)", () => { +/** + * Regression test for bug #2523 + * + * workflows/quick.md Step 8 ("Build file list") listed PLAN.md, SUMMARY.md, + * STATE.md, and mode-conditional CONTEXT.md / RESEARCH.md / VERIFICATION.md — + * but omitted deferred-items.md. When an executor logs out-of-scope findings + * to ${QUICK_DIR}/${quick_id}-deferred-items.md during task execution, that + * file was left untracked after the final commit even with commit_docs: true. + * + * Fix: add a file-existence-gated entry for deferred-items.md to Step 8. + */ + +'use strict'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const WORKFLOW_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'quick.md'); + +describe('bug #2523: quick-task final commit includes deferred-items.md', () => { + const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8'); + + test('workflow file exists', () => { + assert.ok(fs.existsSync(WORKFLOW_PATH), 'gsd-core/workflows/quick.md must exist'); + }); + + test('Step 8 file list references deferred-items.md', () => { + const step8Idx = content.indexOf('Step 8: Final commit'); + assert.notEqual(step8Idx, -1, 'Step 8 section must exist in quick.md'); + + const step8Section = content.slice(step8Idx, step8Idx + 2000); + assert.ok( + step8Section.includes('deferred-items.md'), + 'Step 8 file list must include deferred-items.md. ' + + 'Without this, any out-of-scope findings logged by the executor to ' + + '${QUICK_DIR}/${quick_id}-deferred-items.md are left untracked after commit (bug #2523).' + ); + }); + + test('deferred-items.md entry is conditional on file existence', () => { + const deferredIdx = content.indexOf('deferred-items.md'); + assert.notEqual(deferredIdx, -1, 'deferred-items.md must be mentioned in the workflow'); + + const surroundingContext = content.slice(Math.max(0, deferredIdx - 200), deferredIdx + 200); + const isConditional = + surroundingContext.toLowerCase().includes('exist') || + surroundingContext.toLowerCase().includes('if '); + assert.ok( + isConditional, + 'The deferred-items.md entry must be gated on file existence (not a mode flag). ' + + 'deferred-items.md can be created in any quick-task run, regardless of mode.' + ); + }); +}); + }); +} diff --git a/tests/reapply-patches.test.cjs b/tests/reapply-patches.test.cjs index 317be38a8..1d5f199ae 100644 --- a/tests/reapply-patches.test.cjs +++ b/tests/reapply-patches.test.cjs @@ -514,3 +514,135 @@ describe('reapply-patches pristine baseline detection (#2424)', () => { }); }); } + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3516-reapply-patches-gsd-update-filter.test.cjs — consolidation epic #1969 (B4 #1973) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3516-reapply-patches-gsd-update-filter (consolidation epic #1969 B4 #1973)", () => { +// allow-test-rule: source-text-is-the-product (see #3516) +// gsd-core/workflows/reapply-patches.md is the installed runtime workflow — +// its text IS the deployed behavioral contract for the --reapply flag. + +'use strict'; + +/** + * Bug #3516: reapply-patches.md git-enhanced two-way merge filter misses + * commits authored by the renamed `/gsd-update` flow. + * + * The `grep -v` alternation on line 231 only included the legacy `gsd:update` + * marker. After the slash-command rename `/gsd:update` → `/gsd-update`, commits + * authored by the current flow fall through the filter and are misclassified as + * user customizations, prompting spurious merge conflicts during `--reapply`. + * + * Fix: add `gsd-update` arm to the alternation so both the legacy and current + * commit-message prefixes are excluded. `GSD update` and `gsd-install` + * exclusions are preserved. + * + * Per the repo's source-text-is-the-product exception: the workflow file's text + * IS the deployed behavioral contract. Structural assertion against the parsed + * shell command string is the correct test form here. + */ + +const { describe, test, before } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const WORKFLOW_PATH = path.join( + __dirname, + '..', + 'gsd-core', + 'workflows', + 'reapply-patches.md', +); + +/** + * Extract the git log filter command from the workflow. + * + * Looks for the `grep -v "..."` shell snippet inside the Git-enhanced two-way + * merge section and returns the alternation string between the quotes. + * Returns null if the snippet is absent (signals a structural regression). + */ +function extractFilterAlternation(content) { + // Match the grep -v "..." line in the bash block + const match = content.match(/grep\s+-v\s+"([^"]+)"/); + if (!match) return null; + return match[1]; +} + +/** + * Parse the alternation string (pipe-delimited) into individual arms. + * Handles escaped pipes produced by shell regex syntax (`\|`). + */ +function parseAlternationArms(alternation) { + // Shell grep alternation uses \| (escaped pipe); split on that + return alternation.split(/\\\|/).map((arm) => arm.trim()); +} + +describe('Bug #3516: git-enhanced two-way merge filter includes gsd-update arm', () => { + let content; + let alternation; + let arms; + + before(() => { + content = fs.readFileSync(WORKFLOW_PATH, 'utf8'); + alternation = extractFilterAlternation(content); + arms = alternation ? parseAlternationArms(alternation) : []; + }); + + test('workflow file exists', () => { + assert.ok( + fs.existsSync(WORKFLOW_PATH), + 'gsd-core/workflows/reapply-patches.md must exist', + ); + }); + + test('git-enhanced two-way merge section contains a grep -v filter', () => { + assert.ok( + alternation !== null, + 'reapply-patches.md must contain a `grep -v "..."` filter in the git-enhanced two-way merge section', + ); + }); + + test('filter excludes legacy gsd:update commits (back-compat)', () => { + assert.ok( + arms.some((arm) => arm === 'gsd:update'), + `filter must include 'gsd:update' arm for back-compat; got arms: ${JSON.stringify(arms)}`, + ); + }); + + test('filter excludes renamed gsd-update commits (primary fix)', () => { + assert.ok( + arms.some((arm) => arm === 'gsd-update'), + `filter must include 'gsd-update' arm (renamed flow); got arms: ${JSON.stringify(arms)}`, + ); + }); + + test('filter excludes GSD update commits (no regression)', () => { + assert.ok( + arms.some((arm) => arm === 'GSD update'), + `filter must include 'GSD update' arm; got arms: ${JSON.stringify(arms)}`, + ); + }); + + test('filter excludes gsd-install commits (no regression)', () => { + assert.ok( + arms.some((arm) => arm === 'gsd-install'), + `filter must include 'gsd-install' arm; got arms: ${JSON.stringify(arms)}`, + ); + }); + + test('all four expected exclusion patterns are present in the filter', () => { + const required = ['gsd:update', 'gsd-update', 'GSD update', 'gsd-install']; + const missing = required.filter((p) => !arms.some((arm) => arm === p)); + assert.deepEqual( + missing, + [], + `filter is missing required exclusion patterns: ${JSON.stringify(missing)}; got arms: ${JSON.stringify(arms)}`, + ); + }); +}); + }); +} diff --git a/tests/research-agent-profiles.test.cjs b/tests/research-agent-profiles.test.cjs index c2683d486..6bee6dcfd 100644 --- a/tests/research-agent-profiles.test.cjs +++ b/tests/research-agent-profiles.test.cjs @@ -186,3 +186,128 @@ describe('research-agent-profiles: checkAgent malformed profile', () => { ); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-222-research-synthesizer-write-contract.test.cjs — consolidation epic #1969 (B4 #1973) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-222-research-synthesizer-write-contract (consolidation epic #1969 B4 #1973)", () => { +// allow-test-rule: source-text-is-the-product (see #222) +'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 REPO_ROOT = path.resolve(__dirname, '..'); +const SYNTHESIZER_PATH = path.join(REPO_ROOT, 'agents', 'gsd-research-synthesizer.md'); + +function readSynthesizerPrompt() { + return fs.readFileSync(SYNTHESIZER_PATH, 'utf8'); +} + +describe('bug #222: research synthesizer must write SUMMARY.md via Write tool', () => { + test('step 6 has explicit hard-rule block forbidding return-message content fallback', () => { + const prompt = readSynthesizerPrompt(); + + assert.match( + prompt, + /canonical output of this agent[\s\S]*existing on disk after you return/i, + 'Step 6 must define SUMMARY.md-on-disk as canonical output.' + ); + assert.match( + prompt, + /Hard rules \(must follow\):/i, + 'Step 6 must contain explicit hard rules block.' + ); + assert.match( + prompt, + /Use the `Write` tool[\s\S]*there are no restrictions/i, + 'Rule 1 must force Write tool usage and reject hallucinated restrictions.' + ); + assert.match( + prompt, + /Do NOT return the SUMMARY\.md content in your response/i, + 'Rule 2 must forbid returning SUMMARY content in the response body.' + ); + assert.match( + prompt, + /Do NOT ask permission to write/i, + 'Rule 3 must forbid write-permission asks for this agent.' + ); + assert.match( + prompt, + /Do NOT use `Bash\(cat << 'EOF'\)` or heredoc/i, + 'Rule 4 must forbid heredoc/Bash file creation fallback.' + ); + assert.match( + prompt, + /If the Write tool errors[\s\S]*Do not silently fall back to returning content/i, + 'Rule 5 must force explicit error reporting for Write failures.' + ); + }); +}); + +describe('bug #222 recurrence: orchestrator self-heals when synthesizer returns SUMMARY.md inline', () => { + const WORKFLOWS = [ + path.join(REPO_ROOT, 'gsd-core', 'workflows', 'new-project.md'), + path.join(REPO_ROOT, 'gsd-core', 'workflows', 'new-milestone.md'), + ]; + + for (const wf of WORKFLOWS) { + const name = path.basename(wf); + + test(`${name} has the #222 synthesizer SUMMARY.md self-heal guard`, () => { + const text = fs.readFileSync(wf, 'utf8'); + + // Marker tying the guard to the issue + assert.match(text, /#222[^\n]*self-heal|self-heal[^\n]*#222/i, + `${name} must contain a #222-tagged self-heal guard after the synthesizer returns.`); + + // Verifies the file exists AND is substantive/non-empty + assert.match(text, /SUMMARY\.md[\s\S]{0,120}?(non-empty|substantive|exists)/i, + `${name} must verify .planning/research/SUMMARY.md exists AND is substantive — non-empty.`); + + // Truncation/validator guard: references the continuation sentinel OR the verify-summary CLI + assert.match(text, /gsd:write-continue|verify-summary/i, + `${name} must guard against truncated/invalid SUMMARY.md (sentinel or verify-summary).`); + + // Self-heal must commit ALL research artifacts, not just SUMMARY.md + assert.match(text, /--files \.planning\/research\//, + `${name} self-heal must commit ALL research artifacts, not just SUMMARY.md.`); + + // Persists inline-returned document via Write rather than trusting the agent + assert.match(text, /returned[\s\S]{0,200}?document[\s\S]{0,200}?Write tool/i, + `${name} must instruct the orchestrator to persist inline-returned document with the Write tool.`); + + // Must not proceed to roadmapper against a missing or incomplete SUMMARY.md + assert.match(text, /gsd-roadmapper[\s\S]{0,200}?(missing|incomplete|do NOT)/i, + `${name} must block spawning gsd-roadmapper when SUMMARY.md is missing or incomplete.`); + + // Must name the FULL SUMMARY template markers so the orchestrator persists the real + // document, not the brief structured return (resolves the HIGH finding). + assert.match(text, /# Project Research Summary[\s\S]{0,260}?## Sources/, + `${name}: self-heal must name the full SUMMARY.md template markers (# Project Research Summary … ## Sources).`); + // Must reference the brief confirmation marker it must NOT mistake for file content. + assert.match(text, /## SYNTHESIS COMPLETE/, + `${name}: self-heal must distinguish the brief ## SYNTHESIS COMPLETE confirmation from the real document.`); + }); + + test(`${name} runs the #222 self-heal AFTER the synthesizer and BEFORE gsd-roadmapper`, () => { + const text = fs.readFileSync(wf, 'utf8'); + const synthIdx = text.indexOf('subagent_type="gsd-research-synthesizer"'); + const healIdx = text.indexOf('Synthesizer output self-heal (#222)'); + const roadIdx = text.indexOf('subagent_type="gsd-roadmapper"'); + assert.ok(synthIdx >= 0, `${name}: synthesizer dispatch not found`); + assert.ok(healIdx >= 0, `${name}: #222 self-heal block not found`); + assert.ok(roadIdx >= 0, `${name}: gsd-roadmapper dispatch not found`); + assert.ok(healIdx > synthIdx, `${name}: self-heal must come AFTER the synthesizer dispatch`); + assert.ok(roadIdx > healIdx, `${name}: self-heal must come BEFORE the gsd-roadmapper dispatch`); + }); + } +}); + }); +} diff --git a/tests/review-default-reviewers-workflow.test.cjs b/tests/review-default-reviewers-workflow.test.cjs index 00cb3cda0..daf8097d9 100644 --- a/tests/review-default-reviewers-workflow.test.cjs +++ b/tests/review-default-reviewers-workflow.test.cjs @@ -150,3 +150,208 @@ describe('review workflow source-grounding requirement in build_prompt (#1318)', assert.match(extracted, /cite `path\/to\/file:line`/); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-687-agy-timeout.test.cjs — consolidation epic #1969 (B4 #1973) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-687-agy-timeout (consolidation epic #1969 B4 #1973)", () => { +// allow-test-rule: source-text-is-the-product (see #687) +// review.md is a workflow file whose deployed text IS the runtime contract; the +// agy -p invocation cannot be run in CI, so we assert on its content (issue #687). +'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 reviewPath = path.resolve(__dirname, '..', 'gsd-core', 'workflows', 'review.md'); +const read = () => fs.readFileSync(reviewPath, 'utf-8'); + +describe('bug #687: agy print mode must be bounded via its native --print-timeout', () => { + test('invokes agy with its own --print-timeout flag (not an external killer)', () => { + assert.match(read(), /agy --print-timeout \d+s? -p "\$\(cat/, + 'review.md must cap agy through `agy --print-timeout -p …` (the tool\'s own mechanism)'); + }); + + test('discards partial output on non-zero exit so the fallback fires', () => { + const c = read(); + assert.match(c, /_AGY_RC.*-ne 0/, 'review.md must check the agy exit code'); + assert.match(c, /: > \/tmp\/gsd-review-antigravity-/, + 'review.md must truncate the output file when agy timed out / failed'); + }); + + test('agy is bounded only by its own --print-timeout, not an external process killer', () => { + const c = read(); + // Print-mode reviewers invoke the tool directly; agy must self-terminate via + // --print-timeout, never via an external SIGKILL/timeout binary wrapped around it. + assert.doesNotMatch(c, /-s KILL/, 'must not SIGKILL agy from the outside'); + // Any external timeout binary wrapping agy — `timeout 300s agy …`, + // `gtimeout 300 agy …`, `timeout -s KILL 300 agy …`. The lookbehind keeps + // agy's own `--print-timeout` flag from tripping it. + assert.doesNotMatch(c, /(? { + // A bare `agy -p "$(cat …)"` with no cap was the original hang. + assert.doesNotMatch(read(), /^agy -p "\$\(cat/m, + 'review.md must not invoke agy -p without --print-timeout'); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-773-codex-exec-automation-flags.test.cjs — consolidation epic #1969 (B4 #1973) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-773-codex-exec-automation-flags (consolidation epic #1969 B4 #1973)", () => { +'use strict'; + +// allow-test-rule: source-text-is-the-product (see #773) +// Workflow markdown is runtime contract; these assertions verify that +// automated codex exec invocations carry the correct automation flags. + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +describe('enh-773: automated codex exec invocations include --ephemeral and --dangerously-bypass-hook-trust', () => { + const workflow = fs.readFileSync( + path.join(process.cwd(), 'gsd-core', 'workflows', 'review.md'), + 'utf8' + ); + + // Extract codex exec INVOCATION lines from code fences. The #1115 capability + // probe (`codex exec --help | grep …`) is not an automation invocation, so it + // is excluded from the per-invocation flag assertions below. + const codexExecLines = workflow + .split(/\r?\n/) + .filter((line) => line.includes('codex exec') && !line.includes('codex exec --help')); + + test('review.md contains at least one codex exec invocation', () => { + assert.ok( + codexExecLines.length > 0, + 'review.md must contain at least one codex exec invocation' + ); + }); + + test('every codex exec invocation includes --ephemeral', () => { + for (const line of codexExecLines) { + assert.ok( + line.includes('--ephemeral'), + `codex exec invocation is missing --ephemeral:\n ${line.trim()}` + ); + } + }); + + test('#1115: the hook-trust bypass is capability-gated, not passed unconditionally', () => { + // --dangerously-bypass-hook-trust only exists on codex-cli >= 0.137.0. It must + // be probed (`codex exec --help | grep`) and applied via $CODEX_BYPASS_FLAG so + // older installs do not fail with "unexpected argument" (a silent empty review). + assert.ok( + /codex exec --help[^\r\n]*grep[^\r\n]*--dangerously-bypass-hook-trust/.test(workflow), + 'review.md must capability-probe --dangerously-bypass-hook-trust via `codex exec --help | grep`' + ); + assert.ok( + workflow.includes('CODEX_BYPASS_FLAG="--dangerously-bypass-hook-trust"'), + 'the probe must set CODEX_BYPASS_FLAG to the flag when the CLI supports it' + ); + for (const line of codexExecLines) { + assert.ok( + line.includes('$CODEX_BYPASS_FLAG'), + `codex exec invocation must apply the capability-gated $CODEX_BYPASS_FLAG, not an unconditional flag:\n ${line.trim()}` + ); + // …and must NOT also pass the literal flag (that would reintroduce #1115). + assert.ok( + !line.includes('--dangerously-bypass-hook-trust'), + `codex exec invocation must not pass the literal --dangerously-bypass-hook-trust (use the gated $CODEX_BYPASS_FLAG):\n ${line.trim()}` + ); + } + }); + + test('#1115: codex review failures are surfaced, not silently swallowed', () => { + // stderr must be captured (not discarded to /dev/null) and an empty output + // must be replaced with a diagnostic, so a broken reviewer is reported. + for (const line of codexExecLines) { + assert.ok( + !line.includes('2>/dev/null'), + `codex exec must not discard stderr to /dev/null:\n ${line.trim()}` + ); + } + assert.ok( + /\[ ! -s \/tmp\/gsd-review-codex-\{phase\}\.md \]/.test(workflow), + 'review.md must guard against an empty codex review output and surface the failure' + ); + }); + + test('--ephemeral appears before the prompt argument (flag ordering)', () => { + for (const line of codexExecLines) { + const ephemeralPos = line.indexOf('--ephemeral'); + const promptPos = line.indexOf(' - '); + if (promptPos === -1) continue; // no stdin prompt arg on this line + assert.ok( + ephemeralPos < promptPos, + `--ephemeral must appear before the stdin prompt argument:\n ${line.trim()}` + ); + } + }); + + test('--skip-git-repo-check is preserved alongside automation flags', () => { + for (const line of codexExecLines) { + assert.ok( + line.includes('--skip-git-repo-check'), + `codex exec invocation lost --skip-git-repo-check:\n ${line.trim()}` + ); + } + }); +}); + +describe('#1698 regression: codex review is captured via --output-last-message, not stdout', () => { + // WHY: on some platforms (Windows) `codex exec` writes process-teardown output + // to stdout *after* the final agent message. A `> FILE` stdout redirect appends + // that noise to a non-empty file, so it slips past the `[ ! -s … ]` empty-output + // guard and downstream consumers (severity extraction, the + // plan-review-convergence "concerns resolved?" gate) parse a polluted review. + // `-o/--output-last-message ` writes only the final message — robust on + // every platform — so each codex invocation must capture via -o and discard stdout. + const workflow = fs.readFileSync( + path.join(process.cwd(), 'gsd-core', 'workflows', 'review.md'), + 'utf8' + ); + const codexExecLines = workflow + .split(/\r?\n/) + .filter((line) => line.includes('codex exec') && !line.includes('codex exec --help')); + + test('every codex exec invocation captures the review via -o ', () => { + for (const line of codexExecLines) { + assert.ok( + /\s-o\s+\/tmp\/gsd-review-codex-\{phase\}\.md\b/.test(line), + `codex exec invocation must capture the review via -o /tmp/gsd-review-codex-{phase}.md:\n ${line.trim()}` + ); + } + }); + + test('no codex exec invocation redirects stdout into the review file', () => { + for (const line of codexExecLines) { + assert.ok( + !/>\s*\/tmp\/gsd-review-codex-\{phase\}\.md\b/.test(line), + `codex exec must not redirect stdout into the review file (teardown noise pollutes it); use -o + >/dev/null:\n ${line.trim()}` + ); + assert.ok( + />\s*\/dev\/null\b/.test(line), + `codex exec must discard stdout to /dev/null so teardown output is not captured:\n ${line.trim()}` + ); + } + }); +}); + }); +} diff --git a/tests/secure-phase.test.cjs b/tests/secure-phase.test.cjs index 109faa40a..b676e0795 100644 --- a/tests/secure-phase.test.cjs +++ b/tests/secure-phase.test.cjs @@ -706,3 +706,71 @@ describe('SECURE: security config variables resolved before use (#1625)', () => ); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3120-secure-phase-empty-register.test.cjs — consolidation epic #1969 (B4 #1973) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3120-secure-phase-empty-register (consolidation epic #1969 B4 #1973)", () => { +'use strict'; +// allow-test-rule: reads product workflow markdown (secure-phase.md) to verify structural guard contract — not a source-grep test (see #3120) + +// Regression guard for bug #3120. +// +// secure-phase.md Step 3 short-circuited to Step 6 (write SECURITY.md) +// whenever threats_open: 0, without distinguishing between: +// Case A: All plan-time threat_model threats are CLOSED (legitimate skip) +// Case B: No threat_model blocks were written at plan time (legacy phases) +// → rubber-stamps a clean SECURITY.md with zero audit performed +// +// Fix: Step 2c tracks `register_authored_at_plan_time` (true iff ≥1 PLAN +// file contained a parseable block). Step 3 now requires BOTH +// threats_open: 0 AND register_authored_at_plan_time to skip. If only +// threats_open: 0 and NOT register_authored_at_plan_time, Step 5 runs in +// retroactive-STRIDE mode. + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const ROOT = path.join(__dirname, '..'); +const src = fs.readFileSync( + path.join(ROOT, 'gsd-core', 'workflows', 'secure-phase.md'), + 'utf8', +); + +describe('bug #3120: secure-phase short-circuit guards', () => { + test('Step 2c tracks register_authored_at_plan_time', () => { + assert.ok( + src.includes('register_authored_at_plan_time'), + 'secure-phase.md does not track register_authored_at_plan_time in Step 2c', + ); + }); + + test('Step 3 short-circuit requires both conditions', () => { + assert.ok( + src.includes('threats_open: 0 AND register_authored_at_plan_time'), + 'Step 3 short-circuit does not gate on both threats_open:0 AND register_authored_at_plan_time', + ); + }); + + test('retroactive-STRIDE mode is documented for legacy phases', () => { + assert.ok( + src.includes('retroactive') || src.includes('Retroactive'), + 'secure-phase.md does not document retroactive-STRIDE mode for legacy phases (no blocks)', + ); + }); + + test('Step 5 auditor constraint varies by mode', () => { + assert.ok( + (src.includes('Verify mitigations') || src.includes('verify mitigations')) && + (src.includes('Retroactive') || src.includes('retroactive')), + 'Step 5 does not distinguish planned vs retroactive-STRIDE auditor constraint', + ); + }); +}); + }); +} diff --git a/tests/seed-scan-new-milestone.test.cjs b/tests/seed-scan-new-milestone.test.cjs index 6e5ddc30d..cc34aa6fa 100644 --- a/tests/seed-scan-new-milestone.test.cjs +++ b/tests/seed-scan-new-milestone.test.cjs @@ -63,3 +63,178 @@ describe('seed scanning in new-milestone workflow (#2169)', () => { ); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3236-capture-seed-one-shot.test.cjs — consolidation epic #1969 (B4 #1973) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3236-capture-seed-one-shot (consolidation epic #1969 B4 #1973)", () => { +// allow-test-rule: source-text-is-the-product — workflow and command .md files (see #3236) +// ARE what the runtime loads; asserting their existence and behavioral content +// tests the deployed skill surface contract, not implementation internals. + +'use strict'; + +// Regression tests for bug #3236. +// +// The `plant-seed.md` workflow gained a mandatory Trigger / Why / Scope +// questionnaire (gather_context step) that blocks before the seed file is +// written. Users capturing a stream of ideas lose flow because the AI must +// receive three answers before a single write happens. +// +// Fix: the seed file must be written FIRST (one-shot), with sensible defaults +// for Trigger / Why / Scope. The enrichment questions must be optional and must +// come AFTER the file is written, not before. +// +// Behavioral contract tested here: +// 1. The `write-seed` step exists and comes BEFORE any AskUserQuestion for +// Trigger / Why / Scope enrichment. +// 2. The workflow provides sensible defaults for trigger_when and scope when +// the user supplies only the idea summary. +// 3. The AskUserQuestion calls for Trigger / Why / Scope still exist (optional +// enrichment path preserved) but are gated after the file is written. + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const ROOT = path.join(__dirname, '..'); +const PLANT_SEED = path.join(ROOT, 'gsd-core', 'workflows', 'plant-seed.md'); + +// ── helpers ─────────────────────────────────────────────────────────────────── + +function readPlantSeed() { + try { + return fs.readFileSync(PLANT_SEED, 'utf8'); + } catch (err) { + throw new Error('gsd-core/workflows/plant-seed.md not found: ' + err.message); + } +} + +/** + * Extract step names in document order from workflow XML. + */ +function extractStepNames(src) { + const names = []; + const re = / { + test('plant-seed.md exists', () => { + assert.ok( + fs.existsSync(PLANT_SEED), + 'gsd-core/workflows/plant-seed.md does not exist', + ); + }); + + test('write-seed step exists (hyphenated name per CONTEXT.md rule)', () => { + const src = readPlantSeed(); + const steps = extractStepNames(src); + assert.ok( + steps.includes('write-seed'), + 'plant-seed.md must contain a (hyphens, not underscores); found: ' + JSON.stringify(steps), + ); + }); + + test('write-seed step appears before Trigger AskUserQuestion', () => { + const src = readPlantSeed(); + const writeOff = stepOffset(src, 'write-seed'); + assert.ok(writeOff !== -1, 'write-seed step must exist'); + const triggerOff = askQuestionOffset(src, 'Trigger'); + if (triggerOff === -1) return; // fully optional — no question at all is fine + assert.ok( + writeOff < triggerOff, + 'write-seed (offset ' + writeOff + ') must precede Trigger question (offset ' + triggerOff + ')', + ); + }); + + test('write-seed step appears before Why AskUserQuestion', () => { + const src = readPlantSeed(); + const writeOff = stepOffset(src, 'write-seed'); + assert.ok(writeOff !== -1, 'write-seed step must exist'); + const whyOff = askQuestionOffset(src, 'Why'); + if (whyOff === -1) return; + assert.ok( + writeOff < whyOff, + 'write-seed (offset ' + writeOff + ') must precede Why question (offset ' + whyOff + ')', + ); + }); + + test('write-seed step appears before Scope AskUserQuestion', () => { + const src = readPlantSeed(); + const writeOff = stepOffset(src, 'write-seed'); + assert.ok(writeOff !== -1, 'write-seed step must exist'); + const scopeOff = askQuestionOffset(src, 'Scope'); + if (scopeOff === -1) return; + assert.ok( + writeOff < scopeOff, + 'write-seed (offset ' + writeOff + ') must precede Scope question (offset ' + scopeOff + ')', + ); + }); + + test('workflow documents a default value for trigger_when', () => { + const src = readPlantSeed(); + assert.ok( + /trigger_when.*default|default.*trigger|when relevant|when scope matches|unspecified/i.test(src), + 'plant-seed.md must document a default for trigger_when when the user provides no Trigger', + ); + }); + + test('workflow documents a default value for scope', () => { + const src = readPlantSeed(); + assert.ok( + /scope.*default|default.*scope|scope.*unknown|unknown|scope.*unspecified|unspecified/i.test(src), + 'plant-seed.md must document a default for scope when the user provides no Scope', + ); + }); + + test('write-seed step precedes enrich/gather step in step order', () => { + const src = readPlantSeed(); + const steps = extractStepNames(src); + const writeIdx = steps.findIndex((s) => s === 'write-seed' || s === 'write_seed'); + const gatherIdx = steps.findIndex( + (s) => s === 'gather-context' || s === 'gather_context' || s === 'enrich-seed' || s === 'enrich_seed', + ); + if (gatherIdx === -1 || writeIdx === -1) return; // no gather step — one-shot only path, fine + assert.ok( + writeIdx < gatherIdx, + 'write-seed (step index ' + writeIdx + ') must appear before gather/enrich (index ' + gatherIdx + ') — capture first, enrich later', + ); + }); + + test('optional enrichment path is preserved', () => { + const src = readPlantSeed(); + const hasEnrichStep = /enrich|gather.context|gather_context/i.test(src); + const hasTriggerQuestion = src.includes('header: "Trigger"'); + assert.ok( + hasEnrichStep || hasTriggerQuestion, + 'plant-seed.md must preserve an optional enrichment path (enrich step or Trigger/Why/Scope AskUserQuestion)', + ); + }); +}); + }); +} diff --git a/tests/update-custom-backup.test.cjs b/tests/update-custom-backup.test.cjs index 8e05ad079..28a860f53 100644 --- a/tests/update-custom-backup.test.cjs +++ b/tests/update-custom-backup.test.cjs @@ -531,3 +531,36 @@ describe('detect-custom-files — skills/ directory missing from GSD_MANAGED_DIR }); }); } + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3050-update-backup-eacces-nonfatal.test.cjs — consolidation epic #1969 (B4 #1973) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3050-update-backup-eacces-nonfatal (consolidation epic #1969 B4 #1973)", () => { +'use strict'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +describe('bug #3050: update backup skips unreadable files non-fatally', () => { + test('update workflow backup loop wraps copyFileSync in try/catch and logs non-fatal skip', () => { + const content = fs.readFileSync( + path.join(__dirname, '..', 'gsd-core', 'workflows', 'update.md'), + 'utf8', + ); + + const hasTryCatch = /try\s*\{[\s\S]*copyFileSync\([\s\S]*\}[\s\S]*catch\s*\(err\)/.test(content); + assert.ok(hasTryCatch, 'backup copy loop must catch per-file copy errors'); + + const hasNonFatalSkipMessage = /Skipped \(non-fatal\):/.test(content); + assert.ok( + hasNonFatalSkipMessage, + 'workflow must log a non-fatal skip message for unreadable custom files', + ); + }); +}); + }); +} diff --git a/tests/verify-mvp-uat.test.cjs b/tests/verify-mvp-uat.test.cjs index 25b279b7a..10937c184 100644 --- a/tests/verify-mvp-uat.test.cjs +++ b/tests/verify-mvp-uat.test.cjs @@ -51,3 +51,156 @@ describe('verify-work — MVP mode UAT framing', () => { ); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2504-uat-foundation-phases.test.cjs — consolidation epic #1969 (B4 #1973) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2504-uat-foundation-phases (consolidation epic #1969 B4 #1973)", () => { +/** + * Regression test for bug #2504 + * + * When UAT testing is mandated and a phase has no user-facing elements + * (e.g., code foundations, database schema, internal APIs), the agent + * invented artificial UAT steps — things like "manually run git commits", + * "manually invoke methods", "manually check database state" — and left + * work half-finished specifically to create things for a human to do. + * + * Fix: The verify-phase workflow's identify_human_verification step must + * explicitly handle phases with no user-facing elements by auto-passing UAT + * with a logged rationale instead of inventing manual steps. + */ + +'use strict'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const VERIFY_PHASE_PATH = path.join( + __dirname, '..', 'gsd-core', 'workflows', 'verify-phase.md' +); + +/** + * Extract a named section from a markdown/workflow document. + * Returns the text from `heading` up to (but not including) the next `## ` heading, + * or to end-of-file if no subsequent heading exists. + */ +function extractSection(content, heading) { + const start = content.indexOf(heading); + if (start === -1) return ''; + const nextHeading = content.indexOf('\n## ', start + 1); + return nextHeading === -1 ? content.slice(start) : content.slice(start, nextHeading); +} + +describe('bug #2504: UAT auto-pass for foundation/infrastructure phases', () => { + test('verify-phase workflow file exists', () => { + assert.ok( + fs.existsSync(VERIFY_PHASE_PATH), + 'gsd-core/workflows/verify-phase.md should exist' + ); + }); + + test('identify_human_verification step handles phases with no user-facing elements', () => { + const content = fs.readFileSync(VERIFY_PHASE_PATH, 'utf-8'); + const section = extractSection(content, 'identify_human_verification'); + // The step must explicitly call out the infrastructure/foundation case + const hasInfrastructureHandling = + section.includes('infrastructure') || + section.includes('foundation') || + section.includes('no user-facing') || + section.includes('no user facing') || + section.includes('internal API') || + section.includes('internal APIs') || + section.includes('database schema') || + section.includes('code foundation'); + + assert.ok( + hasInfrastructureHandling, + 'verify-phase.md identify_human_verification step must explicitly handle ' + + 'infrastructure/foundation phases that have no user-facing elements. Without ' + + 'this, agents invent artificial manual steps to satisfy UAT requirements ' + + '(root cause of #2504).' + ); + }); + + test('workflow includes auto-pass or skip UAT language for non-user-facing phases', () => { + const content = fs.readFileSync(VERIFY_PHASE_PATH, 'utf-8'); + const section = extractSection(content, 'identify_human_verification'); + const hasAutoPass = + section.includes('auto-pass') || + section.includes('auto pass') || + section.includes('automatically pass') || + section.includes('skip UAT') || + section.includes('skip the UAT') || + section.includes('UAT does not apply') || + section.includes('UAT not applicable') || + section.includes('no UAT required'); + + assert.ok( + hasAutoPass, + 'verify-phase.md identify_human_verification step must contain language about ' + + 'auto-passing or skipping UAT for phases without user-facing elements. Agents ' + + 'must not invent manual steps when there is nothing user-facing to test ' + + '(root cause of #2504).' + ); + }); + + test('workflow prohibits inventing artificial manual steps for infrastructure phases', () => { + const content = fs.readFileSync(VERIFY_PHASE_PATH, 'utf-8'); + const section = extractSection(content, 'identify_human_verification'); + // The workflow must tell the agent NOT to invent steps when there's nothing to test. + // Look for explicit prohibition or the inverse: "do not invent" or "must not create" + // or equivalent framing like "only require human testing when..." + const hasProhibition = + section.includes('do not invent') || + section.includes('must not invent') || + section.includes('never invent') || + section.includes('Do not invent') || + section.includes('Must not invent') || + section.includes('Never invent') || + section.includes('only require human') || + section.includes('only add human') || + (section.includes('only flag') && section.includes('user-facing')) || + // Or via "N/A" framing + (section.includes('N/A') && ( + section.includes('infrastructure') || + section.includes('foundation') || + section.includes('no user-facing') + )); + + assert.ok( + hasProhibition, + 'verify-phase.md identify_human_verification step must explicitly prohibit ' + + 'inventing artificial manual UAT steps for infrastructure phases. The current ' + + 'wording causes agents to create fake "manually run git commits" steps to ' + + 'satisfy UAT mandates (root cause of #2504).' + ); + }); + + test('workflow includes a concept of N/A or not-applicable UAT state', () => { + const content = fs.readFileSync(VERIFY_PHASE_PATH, 'utf-8'); + const section = extractSection(content, 'identify_human_verification'); + const hasNaState = + section.includes('N/A') || + section.includes('not applicable') || + section.includes('not_applicable') || + section.includes('no_uat') || + section.includes('uat_not_applicable') || + section.includes('infrastructure phase') || + section.includes('foundation phase'); + + assert.ok( + hasNaState, + 'verify-phase.md identify_human_verification step must include some concept of ' + + 'a "not applicable" or N/A UAT state for phases with no user-facing elements. ' + + 'This prevents agents from blocking phase completion on invented manual steps ' + + '(root cause of #2504).' + ); + }); +}); + }); +} diff --git a/tests/verify-work-auto-transition.test.cjs b/tests/verify-work-auto-transition.test.cjs index b21382b9a..221217a00 100644 --- a/tests/verify-work-auto-transition.test.cjs +++ b/tests/verify-work-auto-transition.test.cjs @@ -128,3 +128,93 @@ describe('verify-work.md — auto-transition after UAT passes with 0 issues', () ); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3381-verify-work-workstream.test.cjs — consolidation epic #1969 (B4 #1973) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3381-verify-work-workstream (consolidation epic #1969 B4 #1973)", () => { +// allow-test-rule: source-text-is-the-product — verify-work.md is a runtime workflow contract. (see #3381) + +'use strict'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +describe('bug #1716: resume_from_file routes to complete_session when no [pending] tests remain', () => { + test('resume_from_file step contains guard clause for zero-pending (all-blocked) case', () => { + const workflow = fs.readFileSync( + path.join(__dirname, '..', 'gsd-core', 'workflows', 'verify-work.md'), + 'utf8', + ); + + const stepStart = workflow.indexOf(''); + assert.ok(stepStart !== -1, 'resume_from_file step must exist'); + + const stepEnd = workflow.indexOf('', stepStart); + const stepBody = workflow.slice(stepStart, stepEnd); + + // Guard must appear immediately after the find-pending instruction. + // Without it, all-blocked sessions (pending_count==0, blocked_count>0) + // silently terminate and never reach complete_session (#1716). + const findIdx = stepBody.indexOf("Find first test with `result: [pending]`."); + const guardIdx = stepBody.indexOf("If no `[pending]` test found → go to `complete_session`."); + + assert.ok(findIdx !== -1, 'find-pending instruction must be present'); + assert.ok(guardIdx !== -1, 'guard clause for zero-pending case must be present'); + assert.ok(guardIdx > findIdx, 'guard must appear after find-pending instruction'); + + const between = stepBody + .slice(findIdx + "Find first test with `result: [pending]`.".length, guardIdx) + .trim(); + assert.strictEqual(between, '', 'guard must be the next non-whitespace line after find-pending'); + }); +}); + +describe('bug #3381: verify-work forwards workstream context', () => { + test('workflow forwards ${GSD_WS} to workstream-sensitive SDK queries', () => { + const workflow = fs.readFileSync( + path.join(__dirname, '..', 'gsd-core', 'workflows', 'verify-work.md'), + 'utf8', + ); + + assert.match(workflow, /GSD_WS=""/, 'verify-work must initialize GSD_WS'); + assert.match( + workflow, + /grep -qE -- '--ws\[\[:space:\]\]\+\[\^\[:space:\]\]\+'/, + 'verify-work must detect --ws in $ARGUMENTS', + ); + assert.match( + workflow, + /grep -oE -- '--ws\[\[:space:\]\]\+\[\^\[:space:\]\]\+'/, + 'verify-work must extract the --ws flag pair from $ARGUMENTS', + ); + assert.match( + workflow, + /PHASE_ARG=\$\(echo "\$ARGUMENTS" \| sed -E 's\/--ws\[\[:space:\]\]\+\[\^\[:space:\]\]\+\/\/g' \| xargs\)/, + 'verify-work must derive PHASE_ARG after removing --ws', + ); + // After #3797 architectural fix, callsites use gsd_run + assert.match( + workflow, + /gsd_run query init\.verify-work "\$\{PHASE_ARG\}" \$\{GSD_WS\}/, + 'init.verify-work must receive GSD_WS so phase_dir resolves in workstreams', + ); + assert.match( + workflow, + /gsd_run query phase\.mvp-mode "\$\{phase_number\}" \$\{GSD_WS\} --pick active/, + 'phase.mvp-mode must receive GSD_WS so roadmap mode is workstream-scoped', + ); + assert.match( + workflow, + /gsd_run query roadmap\.get-phase "\$\{phase_number\}" \$\{GSD_WS\} --pick goal/, + 'roadmap.get-phase must receive GSD_WS so goals are workstream-scoped', + ); + }); +}); + }); +} diff --git a/tests/worktree-cleanup.test.cjs b/tests/worktree-cleanup.test.cjs index fd60b60cb..a7dde1ded 100644 --- a/tests/worktree-cleanup.test.cjs +++ b/tests/worktree-cleanup.test.cjs @@ -798,3 +798,477 @@ describe('bug #48: orchestrator fail-closed handling of verify-only halts', () = assert.ok(/(blocked|do NOT merge|not merge)/i.test(withoutDispatchNote), 'execute-phase.md must document an orchestrator-side rule that an executor FATAL/exit 42 marks the plan blocked and is not merged (#48)'); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2384-post-merge-deletion-audit.test.cjs — consolidation epic #1969 (B4 #1973) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2384-post-merge-deletion-audit (consolidation epic #1969 B4 #1973)", () => { +'use strict'; + +/** + * Regression test for #2384. + * + * During execute-phase, the orchestrator merges per-plan worktree branches into + * main. The pre-merge deletion check (git diff --diff-filter=D HEAD...WT_BRANCH) + * only catches files deleted on the worktree branch. A post-merge audit is also + * required to catch deletions that made it into the merge commit (e.g., files + * that were in the common ancestor but deleted by the merged worktree) and to + * provide a revert safety net. + * + * After #3797: execute-phase.md delegates worktree cleanup to the SDK's + * worktree.cleanup-wave command, which implements pre-merge deletion checks + * (diff --diff-filter=D) internally via executeWorktreeWaveCleanupPlan. + * The manual post-merge shell audit (MERGE_DEL_COUNT, git reset --hard) has + * been removed from the workflow — it was part of the SDK-absence fallback. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const EXECUTE_PHASE = path.join( + __dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md' +); + +/** + * Parse execute-phase.md into a structured contract object. + * Returns typed boolean fields so tests can assert on structure + * rather than raw text. + */ +function parseExecutePhaseContract(filePath) { + const lines = fs.readFileSync(filePath, 'utf-8').split(/\r?\n/); + return { + // Does the workflow call the worktree.cleanup-wave SDK command? + delegatesToCleanupWave: lines.some(l => l.includes('worktree.cleanup-wave')), + // Does the cleanup-wave invocation use || exit 1 (fail-closed)? + cleanupWaveFailClosed: lines.some( + l => /gsd_run query worktree\.cleanup-wave.*\|\| exit 1/.test(l), + ), + // Does the workflow export/reference WAVE_WORKTREE_MANIFEST for the SDK? + passesWaveManifest: lines.some(l => l.includes('WAVE_WORKTREE_MANIFEST')), + }; +} + +describe('execute-phase.md — post-merge deletion audit (#2384)', () => { + const contract = parseExecutePhaseContract(EXECUTE_PHASE); + + test('execute-phase delegates to worktree.cleanup-wave (which handles deletion audit)', () => { + // After #3797: worktree.cleanup-wave in worktree-safety.cjs performs + // diff --diff-filter=D checks (blocks branches with deletions) before merge. + // The workflow delegates to the SDK rather than duplicating the check inline. + assert.ok( + contract.delegatesToCleanupWave, + 'execute-phase.md must delegate to gsd_run query worktree.cleanup-wave (#2384/#3797)', + ); + }); + + test('execute-phase cleanup-wave uses || exit 1 (fail-closed for blocked deletions)', () => { + // If worktree.cleanup-wave detects deletions, it exits 1 (blocked). + // The || exit 1 in the workflow propagates that refusal rather than swallowing it. + assert.ok( + contract.cleanupWaveFailClosed, + 'execute-phase.md must use || exit 1 so deletion-blocked cleanups surface to the orchestrator', + ); + }); + + test('execute-phase still has pre-merge deletion check (via guard before worktree.cleanup-wave)', () => { + // The primary deletion guard is now in worktree-safety.cjs (SDK). + // The workflow must still enforce WAVE_WORKTREE_MANIFEST so the SDK + // has the info it needs to validate branches. + assert.ok( + contract.passesWaveManifest, + 'execute-phase.md must pass WAVE_WORKTREE_MANIFEST to worktree.cleanup-wave', + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2501-resurrection-detection.test.cjs — consolidation epic #1969 (B4 #1973) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2501-resurrection-detection (consolidation epic #1969 B4 #1973)", () => { +/** + * Tests for bug #2501: resurrection-detection block in execute-phase.md must + * check git history before deleting new .planning/ files. + * + * Root cause: the original logic deleted ANY .planning/ file that was absent + * from PRE_MERGE_FILES, which includes brand-new files (e.g. SUMMARY.md) + * that the executor just created. A true "resurrection" is a file that was + * previously tracked on main, deliberately deleted, and then re-introduced by + * a worktree merge. Detecting that requires a git history check, not just a + * pre-merge tree membership check. + * + * After #3797: execute-phase.md delegates worktree cleanup to the SDK's + * worktree.cleanup-wave command. Resurrection detection is handled internally + * by the SDK. The inline WAS_DELETED shell check has been removed from the + * workflow — it was part of the SDK-absence fallback which is no longer needed + * since the preflight block exits if neither local nor global SDK is available. + */ + +'use strict'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const EXECUTE_PHASE = path.join( + __dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md' +); + +describe('execute-phase.md — resurrection-detection guard (#2501)', () => { + let content; + + // Load once; each test reads from the cached string. + test('file is readable', () => { + content = fs.readFileSync(EXECUTE_PHASE, 'utf-8'); + assert.ok(content.length > 0, 'execute-phase.md must not be empty'); + }); + + test('cleanup delegates to SDK (handles resurrection detection internally)', () => { + if (!content) content = fs.readFileSync(EXECUTE_PHASE, 'utf-8'); + // After #3797: execute-phase.md delegates to worktree.cleanup-wave, which + // handles pre-merge deletion checks internally. The SDK checks diff --diff-filter=D + // before merging, blocking branches that contain file deletions (#2384/#2501). + assert.ok( + content.includes('worktree.cleanup-wave'), + 'execute-phase.md must delegate to worktree.cleanup-wave (#2501/#3797)', + ); + }); + + test('execute-phase does not use the buggy PRE_MERGE_FILES form', () => { + if (!content) content = fs.readFileSync(EXECUTE_PHASE, 'utf-8'); + // The buggy pattern from before #2501 — deletion conditioned on absence + // from PRE_MERGE_FILES snapshot. Must remain absent. + const hasBuggyGuard = + content.includes('PRE_MERGE_FILES') && + /if\s*!\s*echo\s*"\$PRE_MERGE_FILES"\s*\|\s*grep\s+-qxF\s*"\$RESURRECTED"/.test(content); + assert.ok( + !hasBuggyGuard, + 'execute-phase.md must NOT delete files based on the PRE_MERGE_FILES snapshot grep (inverted guard bug #2501)', + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3195-quick-resurrection-guard.test.cjs — consolidation epic #1969 (B4 #1973) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3195-quick-resurrection-guard (consolidation epic #1969 B4 #1973)", () => { +/** + * Drift-guard for bug #3195: quick.md and execute-phase.md must both use + * the same resurrection-detection approach so they stay in sync. + * + * After #3797: both workflows delegate worktree cleanup to the SDK's + * worktree.cleanup-wave command, which implements resurrection detection + * (diff --diff-filter=D history checks) internally. The inline WAS_DELETED + * shell variable form has been removed from both workflows — it was part of + * the SDK-absence fallback which is now dead code since preflight exits if + * neither local nor global SDK is available. + * + * This test ensures both workflows continue to use the same cleanup + * mechanism (SDK delegation), not one inline and one delegated. + */ + +'use strict'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const QUICK_MD = path.join( + __dirname, '..', 'gsd-core', 'workflows', 'quick.md' +); +const EXECUTE_PHASE_MD = path.join( + __dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md' +); + +describe('resurrection guard drift check — quick.md vs execute-phase.md (#3195)', () => { + let quickContent; + let executePhaseContent; + + test('both workflow files are readable', () => { + quickContent = fs.readFileSync(QUICK_MD, 'utf-8'); + executePhaseContent = fs.readFileSync(EXECUTE_PHASE_MD, 'utf-8'); + assert.ok(quickContent.length > 0, 'quick.md must not be empty'); + assert.ok(executePhaseContent.length > 0, 'execute-phase.md must not be empty'); + }); + + test('quick.md delegates resurrection detection to SDK (worktree.cleanup-wave)', () => { + if (!quickContent) quickContent = fs.readFileSync(QUICK_MD, 'utf-8'); + // After #3797: quick.md delegates to worktree.cleanup-wave, which handles + // resurrection detection (diff --diff-filter=D) internally. The inline + // WAS_DELETED form has been removed — it was part of the SDK-absence fallback. + assert.ok( + quickContent.includes('worktree.cleanup-wave'), + 'quick.md must delegate to worktree.cleanup-wave for resurrection detection (#3195/#3797)' + ); + }); + + test('execute-phase.md delegates resurrection detection to SDK (worktree.cleanup-wave)', () => { + if (!executePhaseContent) executePhaseContent = fs.readFileSync(EXECUTE_PHASE_MD, 'utf-8'); + // After #3797: execute-phase.md delegates to worktree.cleanup-wave, which handles + // resurrection detection (diff --diff-filter=D) internally. + assert.ok( + executePhaseContent.includes('worktree.cleanup-wave'), + 'execute-phase.md must delegate to worktree.cleanup-wave for resurrection detection (#3195/#3797)' + ); + }); + + test('both workflows use the same cleanup mechanism (SDK delegation parity)', () => { + if (!quickContent) quickContent = fs.readFileSync(QUICK_MD, 'utf-8'); + if (!executePhaseContent) executePhaseContent = fs.readFileSync(EXECUTE_PHASE_MD, 'utf-8'); + const quickDelegates = quickContent.includes('worktree.cleanup-wave'); + const executeDelegates = executePhaseContent.includes('worktree.cleanup-wave'); + assert.strictEqual( + quickDelegates, + executeDelegates, + 'quick.md and execute-phase.md must both use the same cleanup mechanism (SDK delegation parity, #3195)' + ); + }); + + test('quick.md does not use the buggy PRE_MERGE_FILES grep form', () => { + if (!quickContent) quickContent = fs.readFileSync(QUICK_MD, 'utf-8'); + // The buggy pattern: deletion conditioned on absence from PRE_MERGE_FILES snapshot + const hasBuggyGuard = + quickContent.includes('PRE_MERGE_FILES') && + /if\s*!\s*echo\s*"\$PRE_MERGE_FILES"\s*\|\s*grep\s+-qxF\s*"\$RESURRECTED"/.test(quickContent); + assert.ok( + !hasBuggyGuard, + 'quick.md must NOT delete files based on the PRE_MERGE_FILES snapshot grep (inverted guard bug #3195)' + ); + }); + + test('execute-phase.md does not use the buggy PRE_MERGE_FILES grep form', () => { + if (!executePhaseContent) executePhaseContent = fs.readFileSync(EXECUTE_PHASE_MD, 'utf-8'); + const hasBuggyGuard = + executePhaseContent.includes('PRE_MERGE_FILES') && + /if\s*!\s*echo\s*"\$PRE_MERGE_FILES"\s*\|\s*grep\s+-qxF\s*"\$RESURRECTED"/.test(executePhaseContent); + assert.ok( + !hasBuggyGuard, + 'execute-phase.md must NOT delete files based on the PRE_MERGE_FILES snapshot grep (inverted guard bug)' + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3521-quick-cleanup-cwd-pin.test.cjs — consolidation epic #1969 (B4 #1973) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3521-quick-cleanup-cwd-pin (consolidation epic #1969 B4 #1973)", () => { +// allow-test-rule: source-text-is-the-product (see #3521) +// quick.md is the shipped orchestration contract for /gsd-quick; this +// regression test previously locked the CWD-safety guard in the manual shell +// cleanup loop. After #3797, quick.md delegates cleanup entirely to the SDK's +// worktree.cleanup-wave command, which encapsulates CWD-pinning, STATE.md/ +// ROADMAP.md backup/restore, and deletion guards internally. +// +// This test file now verifies the delegation contract: quick.md calls +// worktree.cleanup-wave with || exit 1 (fail-closed), which enforces the +// safety semantics that were previously implemented inline in the shell loop. + +'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 QUICK_MD = path.join(__dirname, '..', 'gsd-core', 'workflows', 'quick.md'); + +function readQuickMd() { + return fs.readFileSync(QUICK_MD, 'utf8'); +} + +describe('bug #3521 — quick.md post-merge cleanup CWD safety (via SDK delegation, #3797)', () => { + + test('quick.md is readable', () => { + const content = readQuickMd(); + assert.ok(content.length > 0, 'quick.md must not be empty'); + }); + + test('quick.md cleanup delegates CWD-safe worktree cleanup to SDK (worktree.cleanup-wave)', () => { + const content = readQuickMd(); + // After #3797: quick.md delegates to gsd_run query worktree.cleanup-wave + // which handles CWD pinning, STATE.md backup, deletion guards, and branch + // cleanup internally. The manual shell loop has been removed. + assert.ok( + content.includes('worktree.cleanup-wave'), + 'quick.md must delegate cleanup to gsd_run query worktree.cleanup-wave (#3797)', + ); + }); + + test('quick.md cleanup-wave call uses || exit 1 to enforce fail-closed safety (#3521 contract)', () => { + const content = readQuickMd(); + // The || exit 1 enforces fail-closed: SDK safety refusals (e.g. branch + // drift detection from #3174) surface immediately rather than being swallowed. + // This is the equivalent of the pre-#3797 `gsd_run query ... || exit 1` in the + // `if command -v gsd-sdk` branch. + assert.match( + content, + /gsd_run query worktree\.cleanup-wave.*\|\| exit 1/, + 'quick.md cleanup-wave must use || exit 1 — fail-closed for safety refusals (#3521/#3797)', + ); + }); + + test('quick.md manifest guard still blocks broad cleanup when manifest is missing (#3384)', () => { + const content = readQuickMd(); + // The manifest guard must still be present before the cleanup-wave call + // to prevent broad worktree cleanup when the manifest file is absent. + assert.ok( + content.includes('QUICK_WORKTREE_MANIFEST') || content.includes('WAVE_WORKTREE_MANIFEST'), + 'quick.md must still guard cleanup behind QUICK_WORKTREE_MANIFEST (#3384)', + ); + assert.ok( + content.includes('refusing broad worktree cleanup') || content.includes('missing QUICK_WORKTREE_MANIFEST'), + 'quick.md must emit a blocked message when the manifest is missing (#3384)', + ); + }); + +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2838-summary-rescue-gitignored-planning.test.cjs — consolidation epic #1969 (B4 #1973) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2838-summary-rescue-gitignored-planning (consolidation epic #1969 B4 #1973)", () => { +/** + * Regression tests for #2838: SUMMARY rescue silently fails when .planning/ + * is gitignored. + * + * After #3797: execute-phase.md and quick.md delegate worktree cleanup to the + * SDK's worktree.cleanup-wave command. The SDK's executeWorktreeWaveCleanupPlan + * handles SUMMARY rescue internally using a filesystem-level find+cp approach + * (bypassing gitignore) rather than the old git ls-files --exclude-standard + * form that silently dropped gitignored files. + * + * The inline "Safety net" shell rescue block that was previously in both + * workflow files has been removed — it was part of the SDK-absence fallback + * which is now dead code since preflight exits if neither local nor global SDK + * is available. + * + * This test file verifies that both workflows correctly delegate to the SDK + * for SUMMARY rescue, and that neither workflow retains the broken inline form. + */ + +'use strict'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const REPO_ROOT = path.join(__dirname, '..'); +const EXECUTE_PHASE_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'execute-phase.md'); +const QUICK_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'quick.md'); + +/** + * Parse a workflow markdown file into a structured contract object. + * Returns typed boolean fields so tests assert on structure, not raw text. + */ +function parseWorkflowContract(filePath) { + const lines = fs.readFileSync(filePath, 'utf-8').split(/\r?\n/); + return { + // Non-empty check + nonEmpty: lines.length > 0 && lines.some(l => l.length > 0), + // Does the workflow delegate SUMMARY rescue to worktree.cleanup-wave? + delegatesToCleanupWave: lines.some(l => l.includes('worktree.cleanup-wave')), + // Does the cleanup-wave invocation use || exit 1 (fail-closed)? + cleanupWaveFailClosed: lines.some( + l => /gsd_run query worktree\.cleanup-wave.*\|\| exit 1/.test(l), + ), + // Does the workflow still contain the broken ls-files --exclude-standard rescue form? + hasBrokenLsFilesForm: lines.some( + l => l.includes('ls-files --modified --others --exclude-standard'), + ), + }; +} + +const executePhaseContract = parseWorkflowContract(EXECUTE_PHASE_PATH); +const quickContract = parseWorkflowContract(QUICK_PATH); + +describe('bug-2838: SUMMARY rescue delegates to SDK (worktree.cleanup-wave)', () => { + + test('execute-phase.md is readable', () => { + assert.ok(executePhaseContract.nonEmpty, 'execute-phase.md must not be empty'); + }); + + test('quick.md is readable', () => { + assert.ok(quickContract.nonEmpty, 'quick.md must not be empty'); + }); + + test('execute-phase.md delegates SUMMARY rescue to SDK (worktree.cleanup-wave)', () => { + // After #3797: worktree.cleanup-wave handles SUMMARY rescue via find+cp + // (bypasses gitignore, fixing the #2838 bug). The workflow delegates to the + // SDK rather than implementing rescue inline. + assert.ok( + executePhaseContract.delegatesToCleanupWave, + 'execute-phase.md must delegate to worktree.cleanup-wave for SUMMARY rescue (#2838/#3797)', + ); + }); + + test('quick.md delegates SUMMARY rescue to SDK (worktree.cleanup-wave)', () => { + // After #3797: worktree.cleanup-wave handles SUMMARY rescue via find+cp + // (bypasses gitignore, fixing the #2838 bug). + assert.ok( + quickContract.delegatesToCleanupWave, + 'quick.md must delegate to worktree.cleanup-wave for SUMMARY rescue (#2838/#3797)', + ); + }); + + test('execute-phase.md does not retain broken git ls-files --exclude-standard rescue form (#2838)', () => { + // The broken form used --exclude-standard which silently filtered out + // gitignored .planning/ files — the root cause of #2838. + assert.ok( + !executePhaseContract.hasBrokenLsFilesForm, + 'execute-phase.md must not use ls-files --exclude-standard for SUMMARY rescue (broken for gitignored .planning/)', + ); + }); + + test('quick.md does not retain broken git ls-files --exclude-standard rescue form (#2838)', () => { + assert.ok( + !quickContract.hasBrokenLsFilesForm, + 'quick.md must not use ls-files --exclude-standard for SUMMARY rescue (broken for gitignored .planning/)', + ); + }); + + test('execute-phase.md cleanup-wave uses || exit 1 (fail-closed so rescue errors surface)', () => { + // If the SDK's rescue fails (e.g. filesystem error), || exit 1 surfaces + // the failure to the orchestrator rather than silently continuing and + // losing the SUMMARY. + assert.ok( + executePhaseContract.cleanupWaveFailClosed, + 'execute-phase.md cleanup-wave must use || exit 1 so SUMMARY rescue failures surface (#2838/#3797)', + ); + }); + + test('quick.md cleanup-wave uses || exit 1 (fail-closed so rescue errors surface)', () => { + assert.ok( + quickContract.cleanupWaveFailClosed, + 'quick.md cleanup-wave must use || exit 1 so SUMMARY rescue failures surface (#2838/#3797)', + ); + }); +}); + }); +}