From 85ed50cc4f88f9b0f0b9292753231303a816dbbc Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 3 Jul 2026 01:54:30 -0400 Subject: [PATCH] test(#1972): consolidate 94 command/module regression tests into subject suites Fold 94 issue-named command/module regression files into the canonical test file that owns each subject-under-test, across 52 existing suites (state, config, frontmatter, roadmap-parser, capability-registry, shell-command-projection-dispatch, plan-phase-drift-guard, health-validation, runtime-converters, commands, etc.). Verbatim block-scoped describe wrappers; 881 subtests conserved 1:1. No new test files. Host-env pre-check (per B2): the only GSD_WORKSTREAM/GSD_PROJECT-touching destinations (intel, planning-workspace) clear those vars hermetically, so folded CLI tests are safe. Regenerates regression-name allowlist (222->162), ratchets file-count allowlist across 8 buckets (validate entry removed after dropping <=2), makes 34 relocated allow-test-rule exemptions issue-ref-compliant (ADR-456; prunes 34 stale ids). Repoints CONTEXT.md + ADR-0002/443/1235/3524 test-file references. lint:ci green. Part of epic #1969. Closes #1972. Co-Authored-By: Claude Opus 4.8 --- CONTEXT.md | 2 +- ...0002-command-contract-validation-module.md | 2 +- ...iptor-driven-agent-conversion-migration.md | 2 +- docs/adr/3524-cjs-sdk-hard-seam.md | 2 +- ...48-unified-effort-and-fast-mode-routing.md | 2 +- .../lint-allow-test-rule-refs.allowlist.json | 34 - .../lint-regression-test-names.allowlist.json | 62 +- scripts/lint-test-file-count.allowlist.json | 30 +- ...05-w006-i001-cjs-drift-regression.test.cjs | 256 ---- .../4-phase-complete-cjs-regression.test.cjs | 1099 ---------------- tests/artifacts.test.cjs | 150 +++ tests/audit-command-cutover.test.cjs | 501 +++++++ ...ug-10-semver-policy-consolidation.test.cjs | 30 - ...bug-14-progress-auto-flag-dropped.test.cjs | 73 - ...5-state-patch-clobbers-phase-name.test.cjs | 113 -- tests/bug-1829-inherit-model-profile.test.cjs | 218 --- tests/bug-190-bridge-collapse.test.cjs | 45 - tests/bug-1906-hook-relative-paths.test.cjs | 49 - tests/bug-1967-cache-invalidation.test.cjs | 106 -- ...eapply-patches-baseline-detection.test.cjs | 89 -- tests/bug-2530-valid-config-keys.test.cjs | 163 --- tests/bug-2554-decimal-phase-filter.test.cjs | 80 -- .../bug-2557-gemini-local-hook-paths.test.cjs | 52 - ...2638-sub-repos-canonical-location.test.cjs | 143 -- tests/bug-2660-one-liner-extraction.test.cjs | 82 -- ...bug-2770-annotate-deps-int-coerce.test.cjs | 204 --- ...ug-2798-context-window-config-key.test.cjs | 101 -- tests/bug-2801-ingest-docs-handler.test.cjs | 150 --- ...2836-audit-open-summary-uat-drift.test.cjs | 181 --- .../bug-2851-workflow-bare-gsd-tools.test.cjs | 205 --- ...config-get-context-window-default.test.cjs | 146 -- .../bug-2948-spike-wrap-up-dispatch.test.cjs | 162 --- .../bug-2949-sketch-wrap-up-dispatch.test.cjs | 66 - ...-2954-help-md-slash-command-stubs.test.cjs | 183 --- ...86-config-schema-mutation-killers.test.cjs | 145 -- ...6-global-skills-base-runtime-path.test.cjs | 346 ----- ...3127-state-begin-phase-idempotent.test.cjs | 179 --- ...28-roadmap-plan-count-slug-layout.test.cjs | 89 -- ...bug-3135-capture-backlog-workflow.test.cjs | 130 -- ...3156-plan-phase-opencode-dispatch.test.cjs | 122 -- tests/bug-3168-task-to-agent-rename.test.cjs | 114 -- ...21-config-defaults-clone-strategy.test.cjs | 23 - ...2-execute-phase-stall-safe-resume.test.cjs | 103 -- ...g-3227-config-set-model-overrides.test.cjs | 119 -- ...242-state-update-progress-trample.test.cjs | 470 ------- ...258-no-stale-gsd-intel-references.test.cjs | 109 -- ...bug-3413-shell-command-projection.test.cjs | 278 ---- tests/bug-3418-progress-flag-routing.test.cjs | 33 - tests/bug-3431-debug-command-yaml.test.cjs | 28 - .../bug-3441-path-action-projection.test.cjs | 136 -- .../bug-3584-runtime-slash-formatter.test.cjs | 388 ------ ...ity-update-runtime-classification.test.cjs | 111 -- ...659-applysurface-prune-skill-dirs.test.cjs | 257 ---- ...command-cross-reference-invariant.test.cjs | 70 - ...06-ui-safety-gate-false-positives.test.cjs | 398 ------ .../bug-3707-locked-worktree-cleanup.test.cjs | 681 ---------- ...727-code-review-fix-flag-dispatch.test.cjs | 164 --- ...gap-checker-padded-prefix-context.test.cjs | 212 --- tests/bug-384-agents-runtime-aware.test.cjs | 151 --- ...ug-447-gap-analysis-phase-req-ids.test.cjs | 376 ------ ...g-474-clock-seam-date-determinism.test.cjs | 278 ---- .../bug-492-effort-manifest-fallback.test.cjs | 117 -- ...planned-phase-progress-corruption.test.cjs | 145 -- ...pdate-agent-antigravity-detection.test.cjs | 128 -- ...ug-580-local-sh-hook-bash-wrapper.test.cjs | 116 -- ...g-663-redos-roadmap-phase-parsing.test.cjs | 155 --- ...730-milestone-phase-details-scope.test.cjs | 275 ---- .../bug-783-kilo-global-skills-base.test.cjs | 105 -- ...validate-checklist-roadmap-phases.test.cjs | 290 ---- .../bug-924-claude-flat-skill-layout.test.cjs | 189 --- ...950-quick-summary-status-complete.test.cjs | 301 ----- ...974-graphify-budget-missing-value.test.cjs | 362 ----- .../bug-kimi-path-layout-local-guard.test.cjs | 423 ------ tests/capability-registry.test.cjs | 1156 ++++++++++++++++ tests/check-ui-safety-gate.test.cjs | 408 ++++++ tests/clock-seam.test.cjs | 288 ++++ tests/code-review-flags.test.cjs | 174 +++ tests/command-arg-projection.test.cjs | 38 + tests/command-contract.test.cjs | 124 ++ tests/command-routing-hub.test.cjs | 79 ++ tests/commands.test.cjs | 669 ++++++++++ tests/config-field-docs.test.cjs | 128 ++ tests/config-get-default.test.cjs | 267 ++++ tests/config-loader.test.cjs | 153 +++ tests/config-schema.property.test.cjs | 827 ++++++++++++ tests/config.test.cjs | 657 +++++++++ tests/configuration-migrate-config.test.cjs | 33 + ...declarative-reference-antigravity.test.cjs | 259 ++++ tests/docs-parity-live-registry.test.cjs | 193 +++ tests/docs-update.test.cjs | 140 ++ ...55-config-intent-descriptor-drive.test.cjs | 134 -- tests/enh-1082-install-plan-capstone.test.cjs | 238 ---- ...enh-1494-workflow-config-key-docs.test.cjs | 118 -- ...fix-collapse-idempotency.property.test.cjs | 182 --- tests/enh-2310-chunked-plan-phase.test.cjs | 241 ---- tests/enh-2415-claude-md-link-mode.test.cjs | 113 -- tests/enh-2446-milestones-drift.test.cjs | 109 -- tests/enh-2448-artifact-registry.test.cjs | 140 -- tests/enh-2792-namespace-skills.test.cjs | 337 ----- tests/enh-3209-plan-phase-ingest-adr.test.cjs | 80 -- ...h-968-region-scoped-negative-grep.test.cjs | 823 ------------ ...-1173-agent-converters-descriptor.test.cjs | 342 ----- tests/feat-1452-context-guard-mode.test.cjs | 213 --- tests/feat-1754-cli-skew-detection.test.cjs | 85 -- tests/feat-2527-settings-layers.test.cjs | 245 ---- tests/feat-3024-dynamic-routing.test.cjs | 363 ----- tests/feat-3210-fallow-integration.test.cjs | 407 ------ ...command-aliases-manifest-coverage.test.cjs | 278 ---- tests/feat-3262-scan-phase-plans.test.cjs | 310 ----- tests/feat-3309-human-verify-mode.test.cjs | 183 --- ...94-parser-adversarial-frontmatter.test.cjs | 147 --- .../feat-3594-parser-property-style.test.cjs | 141 -- ...5-fs-fault-injection-atomic-write.test.cjs | 480 ------- tests/feat-488-effort-sync.test.cjs | 276 ---- tests/feat-68-per-phase-granularity.test.cjs | 510 ------- ...acklog-excluded-from-total-phases.test.cjs | 295 ----- ...46-total-phases-corrects-downward.test.cjs | 156 --- ...-phase-excluded-from-total-phases.test.cjs | 352 ----- tests/fix-1515-codex-runtime-default.test.cjs | 135 -- ...claude-runtime-default-resolution.test.cjs | 235 ---- tests/fix-1628-config-set-validation.test.cjs | 614 --------- tests/frontmatter.test.cjs | 1125 ++++++++++++++++ tests/gap-checker.property.test.cjs | 386 ++++++ tests/graphify-command-cutover.test.cjs | 372 ++++++ tests/gsd-tools-path-refs.test.cjs | 398 ++++++ tests/health-validation.test.cjs | 730 ++++++++++ tests/ingest-docs.test.cjs | 160 +++ tests/install-runtime-artifacts.test.cjs | 55 + tests/intel.test.cjs | 119 ++ tests/kimi-agent-converter.test.cjs | 433 ++++++ tests/milestone.test.cjs | 91 ++ tests/model-profiles.test.cjs | 534 ++++++++ tests/model-resolver.test.cjs | 355 +++++ tests/phase.test.cjs | 1108 ++++++++++++++++ tests/plan-phase-drift-guard.test.cjs | 721 ++++++++++ tests/planning-workspace.test.cjs | 222 ++++ tests/profile-output.test.cjs | 123 ++ tests/progress-forensic.test.cjs | 43 + tests/progress-mvp-display.test.cjs | 83 ++ tests/reapply-patches.test.cjs | 99 ++ tests/roadmap-parser.test.cjs | 949 +++++++++++++ tests/roadmapper-granularity.test.cjs | 520 ++++++++ ...-artifact-layout-install-profiles.test.cjs | 466 +++++++ .../runtime-config-adapter-registry.test.cjs | 248 ++++ tests/runtime-converters.test.cjs | 742 +++++++++++ tests/runtime-homes-descriptor-drive.test.cjs | 356 +++++ tests/runtime-homes.property.test.cjs | 192 +++ tests/runtime-name-policy.test.cjs | 115 ++ tests/semver-compare.test.cjs | 40 + ...shell-command-projection-dispatch.test.cjs | 1171 +++++++++++++++++ tests/skill-manifest.test.cjs | 347 +++++ tests/state.test.cjs | 1137 ++++++++++++++++ tests/verify.test.cjs | 951 +++++++++++++ tests/worktree-safety.test.cjs | 691 ++++++++++ 154 files changed, 21333 insertions(+), 20523 deletions(-) delete mode 100644 tests/26-w005-w006-i001-cjs-drift-regression.test.cjs delete mode 100644 tests/4-phase-complete-cjs-regression.test.cjs delete mode 100644 tests/bug-10-semver-policy-consolidation.test.cjs delete mode 100644 tests/bug-14-progress-auto-flag-dropped.test.cjs delete mode 100644 tests/bug-1695-state-patch-clobbers-phase-name.test.cjs delete mode 100644 tests/bug-1829-inherit-model-profile.test.cjs delete mode 100644 tests/bug-190-bridge-collapse.test.cjs delete mode 100644 tests/bug-1906-hook-relative-paths.test.cjs delete mode 100644 tests/bug-1967-cache-invalidation.test.cjs delete mode 100644 tests/bug-2424-reapply-patches-baseline-detection.test.cjs delete mode 100644 tests/bug-2530-valid-config-keys.test.cjs delete mode 100644 tests/bug-2554-decimal-phase-filter.test.cjs delete mode 100644 tests/bug-2557-gemini-local-hook-paths.test.cjs delete mode 100644 tests/bug-2638-sub-repos-canonical-location.test.cjs delete mode 100644 tests/bug-2660-one-liner-extraction.test.cjs delete mode 100644 tests/bug-2770-annotate-deps-int-coerce.test.cjs delete mode 100644 tests/bug-2798-context-window-config-key.test.cjs delete mode 100644 tests/bug-2801-ingest-docs-handler.test.cjs delete mode 100644 tests/bug-2836-audit-open-summary-uat-drift.test.cjs delete mode 100644 tests/bug-2851-workflow-bare-gsd-tools.test.cjs delete mode 100644 tests/bug-2943-config-get-context-window-default.test.cjs delete mode 100644 tests/bug-2948-spike-wrap-up-dispatch.test.cjs delete mode 100644 tests/bug-2949-sketch-wrap-up-dispatch.test.cjs delete mode 100644 tests/bug-2954-help-md-slash-command-stubs.test.cjs delete mode 100644 tests/bug-2986-config-schema-mutation-killers.test.cjs delete mode 100644 tests/bug-3126-global-skills-base-runtime-path.test.cjs delete mode 100644 tests/bug-3127-state-begin-phase-idempotent.test.cjs delete mode 100644 tests/bug-3128-roadmap-plan-count-slug-layout.test.cjs delete mode 100644 tests/bug-3135-capture-backlog-workflow.test.cjs delete mode 100644 tests/bug-3156-plan-phase-opencode-dispatch.test.cjs delete mode 100644 tests/bug-3168-task-to-agent-rename.test.cjs delete mode 100644 tests/bug-321-config-defaults-clone-strategy.test.cjs delete mode 100644 tests/bug-3212-execute-phase-stall-safe-resume.test.cjs delete mode 100644 tests/bug-3227-config-set-model-overrides.test.cjs delete mode 100644 tests/bug-3242-state-update-progress-trample.test.cjs delete mode 100644 tests/bug-3258-no-stale-gsd-intel-references.test.cjs delete mode 100644 tests/bug-3413-shell-command-projection.test.cjs delete mode 100644 tests/bug-3418-progress-flag-routing.test.cjs delete mode 100644 tests/bug-3431-debug-command-yaml.test.cjs delete mode 100644 tests/bug-3441-path-action-projection.test.cjs delete mode 100644 tests/bug-3584-runtime-slash-formatter.test.cjs delete mode 100644 tests/bug-3608-antigravity-update-runtime-classification.test.cjs delete mode 100644 tests/bug-3659-applysurface-prune-skill-dirs.test.cjs delete mode 100644 tests/bug-3683-command-cross-reference-invariant.test.cjs delete mode 100644 tests/bug-3706-ui-safety-gate-false-positives.test.cjs delete mode 100644 tests/bug-3707-locked-worktree-cleanup.test.cjs delete mode 100644 tests/bug-3727-code-review-fix-flag-dispatch.test.cjs delete mode 100644 tests/bug-3739-gap-checker-padded-prefix-context.test.cjs delete mode 100644 tests/bug-384-agents-runtime-aware.test.cjs delete mode 100644 tests/bug-447-gap-analysis-phase-req-ids.test.cjs delete mode 100644 tests/bug-474-clock-seam-date-determinism.test.cjs delete mode 100644 tests/bug-492-effort-manifest-fallback.test.cjs delete mode 100644 tests/bug-500-planned-phase-progress-corruption.test.cjs delete mode 100644 tests/bug-503-update-agent-antigravity-detection.test.cjs delete mode 100644 tests/bug-580-local-sh-hook-bash-wrapper.test.cjs delete mode 100644 tests/bug-663-redos-roadmap-phase-parsing.test.cjs delete mode 100644 tests/bug-730-milestone-phase-details-scope.test.cjs delete mode 100644 tests/bug-783-kilo-global-skills-base.test.cjs delete mode 100644 tests/bug-892-validate-checklist-roadmap-phases.test.cjs delete mode 100644 tests/bug-924-claude-flat-skill-layout.test.cjs delete mode 100644 tests/bug-950-quick-summary-status-complete.test.cjs delete mode 100644 tests/bug-974-graphify-budget-missing-value.test.cjs delete mode 100644 tests/bug-kimi-path-layout-local-guard.test.cjs delete mode 100644 tests/enh-1055-config-intent-descriptor-drive.test.cjs delete mode 100644 tests/enh-1082-install-plan-capstone.test.cjs delete mode 100644 tests/enh-1494-workflow-config-key-docs.test.cjs delete mode 100644 tests/enh-1676-path-prefix-collapse-idempotency.property.test.cjs delete mode 100644 tests/enh-2310-chunked-plan-phase.test.cjs delete mode 100644 tests/enh-2415-claude-md-link-mode.test.cjs delete mode 100644 tests/enh-2446-milestones-drift.test.cjs delete mode 100644 tests/enh-2448-artifact-registry.test.cjs delete mode 100644 tests/enh-2792-namespace-skills.test.cjs delete mode 100644 tests/enh-3209-plan-phase-ingest-adr.test.cjs delete mode 100644 tests/enh-968-region-scoped-negative-grep.test.cjs delete mode 100644 tests/feat-1173-agent-converters-descriptor.test.cjs delete mode 100644 tests/feat-1452-context-guard-mode.test.cjs delete mode 100644 tests/feat-1754-cli-skew-detection.test.cjs delete mode 100644 tests/feat-2527-settings-layers.test.cjs delete mode 100644 tests/feat-3024-dynamic-routing.test.cjs delete mode 100644 tests/feat-3210-fallow-integration.test.cjs delete mode 100644 tests/feat-3251-command-aliases-manifest-coverage.test.cjs delete mode 100644 tests/feat-3262-scan-phase-plans.test.cjs delete mode 100644 tests/feat-3309-human-verify-mode.test.cjs delete mode 100644 tests/feat-3594-parser-adversarial-frontmatter.test.cjs delete mode 100644 tests/feat-3594-parser-property-style.test.cjs delete mode 100644 tests/feat-3595-fs-fault-injection-atomic-write.test.cjs delete mode 100644 tests/feat-488-effort-sync.test.cjs delete mode 100644 tests/feat-68-per-phase-granularity.test.cjs delete mode 100644 tests/fix-1445-999x-backlog-excluded-from-total-phases.test.cjs delete mode 100644 tests/fix-1446-total-phases-corrects-downward.test.cjs delete mode 100644 tests/fix-1514-retired-phase-excluded-from-total-phases.test.cjs delete mode 100644 tests/fix-1515-codex-runtime-default.test.cjs delete mode 100644 tests/fix-1521-non-claude-runtime-default-resolution.test.cjs delete mode 100644 tests/fix-1628-config-set-validation.test.cjs diff --git a/CONTEXT.md b/CONTEXT.md index e8d2ff50b..f2abc2440 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -422,7 +422,7 @@ The prompt-level data/instruction isolation seam for untrusted web/document ingr `RULESET.WORKFLOW_SIZE_BUDGET=workflow size enforcement (#1074; BYTES not lines per #717; LF-normalized per #683) = per-file baseline (PRIMARY anti-creep: tests/workflow-size-baseline.json pins each file's exact size) + loose tier hard caps (outer red lines, NEVER raised on approach: XL<=98304 / LARGE<=61440 / DEFAULT<=40960) + new-file cap (un-baselined files <32768, the Codex anchor) + discuss-phase<32000; a file that grew fails the baseline guard — fix with `npm run size:baseline`, commit the one-line diff, and justify the growth in the PR (or extract LAZILY-loaded content; eager @-imports don't reduce loaded context); crossing a hard cap means EXTRACT, not bump` `RULESET.AGENT_SIZE_BUDGET=agent-size-budget (#1074; sibling of WORKFLOW_SIZE_BUDGET; BYTES not lines per #717/#683, rebased from lines in PR 3/3) = per-file baseline (PRIMARY anti-creep: tests/agent-size-baseline.json pins each agents/gsd-*.md exact byte size) + loose tier hard caps (red lines, never raised on approach: XL<=57344 / LARGE<=49152 / DEFAULT<=24576); net-new agents are DEFAULT-tier (no separate new-file cap). One 'npm run size:baseline' regenerates BOTH workflow and agent baselines via the shared scripts/workflow-size.cjs measureMdFiles(dir,predicate) counter. A grown agent fails the baseline guard — regenerate + justify, or extract LAZILY to gsd-core/references/. DISTINCT from DEFECT.AGENT-FILE-SIZE-CAP-BREACH (a separate 45K-CHAR extraction-evidence threshold on gsd-planner via planner-decomposition/reachability tests): that guard proves mode-sections were extracted; this one bounds total agent bytes. Two guards, two units (chars vs bytes), two purposes` `RULESET.WORKFLOW_FILE_NAMES=workflow files use hyphens; XML attributes must match (extract-learnings not extract_learnings); tests should pin exact hyphenated name` -`RULESET.WORKFLOW_EXECUTION_CONTEXT=@-ref in commands/gsd/*.md must resolve to an existing file on disk; regression test in tests/bug-3135-capture-backlog-workflow.test.cjs; INVENTORY.md row + INVENTORY-MANIFEST.json families.workflows must stay in sync; "Invoked by" attribution must move when a flag absorbs a micro-skill` +`RULESET.WORKFLOW_EXECUTION_CONTEXT=@-ref in commands/gsd/*.md must resolve to an existing file on disk; regression test in tests/docs-update.test.cjs (folds former \`bug-3135-capture-backlog-workflow\`, consolidation epic #1969); INVENTORY.md row + INVENTORY-MANIFEST.json families.workflows must stay in sync; "Invoked by" attribution must move when a flag absorbs a micro-skill` `RULESET.WORKFLOW_EXECUTE_END_TO_END=ADR-0002 standard for single-workflow commands is "Execute end-to-end." (no bolded **Follow the X workflow** fragments); flag-dispatch routing uses "execute the X workflow end-to-end." in routing bullets` `RULESET.WORKFLOW.COVERAGE-METADATA=#1602 SUMMARY frontmatter `coverage:` block (list of {id,description,requirement?,verification:[{kind∈unit|integration|e2e|automated_ui|manual_procedural|other, ref, status∈pass|fail|unknown}],human_judgment:bool,rationale?}) is the per-deliverable RTM consumed DETERMINISTICALLY by verify-work extract_tests via `gsd-tools uat classify-coverage --summary ` (src/coverage.cts → bin/lib/coverage.cjs). AUTHORING: execute-plan create_summary populates it from task results; every deliverable MUST be classified; fail-safe default = human_judgment:true + rationale. CLASSIFY CONTRACT: auto-pass (skip human) ONLY when human_judgment===false (strict boolean) AND verification non-empty AND every status==='pass' AND zero validation errors — else PRESENT to human. mode:legacy (no block) ⇒ byte-identical prose `## Accomplishments` fall-through; `coverage: []` ⇒ mode:coverage, zero entries (single-confirmation). Frozen IR: MODE/PRESENT_REASON/ERROR_CODE enums locked by tests/coverage-metadata-parser.test.cjs. extractFrontmatter CANNOT parse it (scalars-only `-` items) → dedicated parser, sibling of parseMustHavesBlock. Asymmetry by design: false-negative=redundant prompt (status quo); false-positive=shipped bug UAT existed to catch` diff --git a/docs/adr/0002-command-contract-validation-module.md b/docs/adr/0002-command-contract-validation-module.md index 093bfd86a..45ecfc545 100644 --- a/docs/adr/0002-command-contract-validation-module.md +++ b/docs/adr/0002-command-contract-validation-module.md @@ -19,7 +19,7 @@ The command file contract defines what makes a valid `commands/gsd/*.md`: Before this ADR, the command contract was enforced inconsistently: - `tests/enh-2790-skill-consolidation.test.cjs` checked existence and frontmatter of specific post-consolidation commands -- `tests/bug-3135-capture-backlog-workflow.test.cjs` checked `execution_context` @-ref resolution (added 2026-05-05) +- `tests/docs-update.test.cjs` (folds former `bug-3135-capture-backlog-workflow`, consolidation epic #1969) checked `execution_context` @-ref resolution (added 2026-05-05) - No test checked `allowed-tools` validity, `name:` convention, or `description:` non-emptiness across all commands simultaneously This meant any PR touching a command file could break the contract without a single test catching it. The `add-backlog.md` gap (#3135) is a concrete example: the workflow file was missing for the full consolidation cycle before a targeted regression test was written. diff --git a/docs/adr/1235-descriptor-driven-agent-conversion-migration.md b/docs/adr/1235-descriptor-driven-agent-conversion-migration.md index 319d75d20..efff11bc5 100644 --- a/docs/adr/1235-descriptor-driven-agent-conversion-migration.md +++ b/docs/adr/1235-descriptor-driven-agent-conversion-migration.md @@ -10,7 +10,7 @@ Agent installation is the last major artifact class in `install()` that does **not** flow through the descriptor-driven path established by ADR-3660 (`installRuntimeArtifacts` → `_copyStaged`, with each runtime's artifacts declared in the capability registry's `artifactLayout`). Skills and kimi-agents already flow through it. The claude-**local** `agents` kind is *declared* in the descriptor (raw-copy, `converter: null`), but production `install()` still produces claude-local agent output via the inline loop — the skills-runtime gate routes Claude through `installRuntimeArtifacts` only when `isGlobal` (`bin/install.js` ~9822/9829), and claude-local commands/agents go through the back-compat copy path. So agents for **every** runtime (claude included) are, in practice, converted and written by a **separate inline loop** in `bin/install.js` (currently lines ~10102–10249). -This was surfaced while attempting #1173: #1227 added the `convertedAgentsKind` plumbing to the layout module, but it is **inert in production** — no runtime descriptor declares an `agents` kind with a non-null `converter`, so `dispatchKindEntry` always routes to the raw-copy `agentsKind`. The plumbing is exercised only by `tests/feat-1173-agent-converters-descriptor.test.cjs` against a synthetic registry. +This was surfaced while attempting #1173: #1227 added the `convertedAgentsKind` plumbing to the layout module, but it is **inert in production** — no runtime descriptor declares an `agents` kind with a non-null `converter`, so `dispatchKindEntry` always routes to the raw-copy `agentsKind`. The plumbing is exercised only by `tests/runtime-converters.test.cjs` (folds former `feat-1173-agent-converters-descriptor`, consolidation epic #1969) against a synthetic registry. A naive cutover (point descriptors at the extracted converters, delete the inline loop) **regresses installs for every runtime**, because the inline loop performs cross-cutting and runtime-specific pipeline steps that the descriptor agents path does not replicate, and because the extracted converters' signature cannot receive the context those steps require. Two of those behaviors have no converter function at all today. Because the migration touches install correctness across ~15 runtimes in the most-edited install module, the cutover strategy and the parity-test contract are architectural decisions worth recording before code. diff --git a/docs/adr/3524-cjs-sdk-hard-seam.md b/docs/adr/3524-cjs-sdk-hard-seam.md index b2b2ab80a..2ad3b5989 100644 --- a/docs/adr/3524-cjs-sdk-hard-seam.md +++ b/docs/adr/3524-cjs-sdk-hard-seam.md @@ -192,7 +192,7 @@ No new behavioral fix is required — the generator pattern extension is the del - `extractTopLevelFunction()` (new in `gen-validate.mjs`) — brace-balanced parser for top-level named function declarations. Used for `canonicalPlanStem`. -**Parity tests:** `tests/26-w005-w006-i001-cjs-drift-regression.test.cjs` — 7 tests. +**Parity tests:** `tests/health-validation.test.cjs` (folds former `26-w005-w006-i001-cjs-drift-regression`, consolidation epic #1969) — 7 tests. - W005: no false positive for `999.1-foo`; W005 still fires for single-digit prefix. - W006-archived: no false W006 for phase archived under `milestones/v1.0-phases/`; unit tests for `MILESTONE_ARCHIVE_DIR_RE` and `PHASE_TOKEN_FROM_DIR_RE` export and behavior. diff --git a/docs/adr/443-opus48-unified-effort-and-fast-mode-routing.md b/docs/adr/443-opus48-unified-effort-and-fast-mode-routing.md index f3c61c44a..f28704ebc 100644 --- a/docs/adr/443-opus48-unified-effort-and-fast-mode-routing.md +++ b/docs/adr/443-opus48-unified-effort-and-fast-mode-routing.md @@ -86,7 +86,7 @@ Common core: `low`, `medium`, `high`, `xhigh`. - Tracking issue: #443 - Prior art (inert reasoning_effort): #2517; `tests/issue-2517-runtime-aware-profiles.test.cjs` -- dynamic_routing escalation: #3024; `tests/feat-3024-dynamic-routing.test.cjs` +- dynamic_routing escalation: #3024; `tests/model-profiles.test.cjs` (folds former `feat-3024-dynamic-routing`, consolidation epic #1969) - phase-type tiers: #3023 - Anthropic effort API: `output_config.effort` (`low`/`medium`/`high`/`xhigh`/`max`); fast mode: `speed` (`standard`/`fast`) - Claude Code effort: `/effort`, `--effort`, `CLAUDE_CODE_EFFORT_LEVEL`, `effortLevel` setting, subagent `effort` frontmatter (anthropics/claude-code #31536, completed); fast mode: `/fast` (interactive only) diff --git a/scripts/lint-allow-test-rule-refs.allowlist.json b/scripts/lint-allow-test-rule-refs.allowlist.json index 0a35133b8..f04ff49cd 100644 --- a/scripts/lint-allow-test-rule-refs.allowlist.json +++ b/scripts/lint-allow-test-rule-refs.allowlist.json @@ -15,11 +15,9 @@ "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-14-progress-auto-flag-dropped.test.cjs :: source-text-is-the-product", "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-1967-cache-invalidation.test.cjs :: source-text-is-the-product", "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", @@ -32,7 +30,6 @@ "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-2424-reapply-patches-baseline-detection.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", @@ -41,36 +38,23 @@ "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-2770-annotate-deps-int-coerce.test.cjs :: source-text-is-the-product", "tests/bug-2772-gitmodules-path-intersection.test.cjs :: source-text-is-the-product", "tests/bug-2784-update-cache-clear-path.test.cjs :: structural-regression-guard", "tests/bug-2808-skill-hyphen-name.test.cjs :: source-text-is-the-product", "tests/bug-2839-review-fix-transactional-cleanup.test.cjs :: source-text-is-the-product", - "tests/bug-2948-spike-wrap-up-dispatch.test.cjs :: source-text-is-the-product", - "tests/bug-2949-sketch-wrap-up-dispatch.test.cjs :: source-text-is-the-product", - "tests/bug-2954-help-md-slash-command-stubs.test.cjs :: source-text-is-the-product", "tests/bug-2973-profile-user-skills-path.test.cjs :: source-text-is-the-product. profile-user.md IS the", "tests/bug-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-3126-global-skills-base-runtime-path.test.cjs :: last three tests read init.cjs source to verify delegation contract to runtime-homes.cjs — structural guard, no behavioral IR exposed", - "tests/bug-3127-state-begin-phase-idempotent.test.cjs :: reads runtime STATE.md written to temp dir — behavioral output test, not source-grep", - "tests/bug-3128-roadmap-plan-count-slug-layout.test.cjs :: reads roadmap.cjs source to verify isPlanFile pattern was adopted — structural contract prevents silent regression to old filter", "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-3135-capture-backlog-workflow.test.cjs :: source-text-is-the-product — workflow and command .md files", - "tests/bug-3156-plan-phase-opencode-dispatch.test.cjs :: source-text-is-the-product", - "tests/bug-3168-task-to-agent-rename.test.cjs :: source-text-is-the-product", "tests/bug-3236-capture-seed-one-shot.test.cjs :: source-text-is-the-product — workflow and command .md files", - "tests/bug-3258-no-stale-gsd-intel-references.test.cjs :: source-text-is-the-product — workflow, reference, and docs .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-3418-progress-flag-routing.test.cjs :: source-text-is-the-product", "tests/bug-3430-planner-phase-contract.test.cjs :: source-text-is-the-product", - "tests/bug-3431-debug-command-yaml.test.cjs :: source-text-is-the-product", "tests/bug-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", @@ -84,19 +68,13 @@ "tests/bug-3677-agent-colon-namespace-leak.test.cjs :: source-text-is-the-product", "tests/bug-3678-executor-commit-docs-respect.test.cjs :: source-text-is-the-product", "tests/bug-3683-command-colon-namespace-leak.test.cjs :: source-text-is-the-product", - "tests/bug-3683-command-cross-reference-invariant.test.cjs :: source-text-is-the-product", "tests/bug-3683-workflow-colon-namespace-leak.test.cjs :: source-text-is-the-product", "tests/bug-3689-resume-glob-nomatch.test.cjs :: source-text-is-the-product", - "tests/bug-3706-ui-safety-gate-false-positives.test.cjs :: source-text-is-the-product", - "tests/bug-3707-locked-worktree-cleanup.test.cjs :: source-text-is-the-product", - "tests/bug-3727-code-review-fix-flag-dispatch.test.cjs :: source-text-is-the-product", "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-474-clock-seam-date-determinism.test.cjs :: source-text-is-the-product", - "tests/bug-503-update-agent-antigravity-detection.test.cjs :: source-text-is-the-product", "tests/bug-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", @@ -106,10 +84,8 @@ "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-924-claude-flat-skill-layout.test.cjs :: source-text-is-the-product", "tests/bug-936-no-nested-spawner-wrap.test.cjs :: source-text-is-the-product", "tests/bug-947-hermes-gsd-prefix.test.cjs :: source-text-is-the-product", - "tests/bug-950-quick-summary-status-complete.test.cjs :: source-text-is-the-product", "tests/bug-patterns-reference.test.cjs :: source-text-is-the-product", "tests/chain-flag-plan-phase.test.cjs :: source-text-is-the-product", "tests/changeset-cli.test.cjs :: reads a product workflow .md file (not CJS source) to verify", @@ -148,18 +124,12 @@ "tests/edge-probe-planner-contract.test.cjs :: runtime-contract-is-the-product — plan-phase.md's planner prompt is the deployed runtime contract under assertion", "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-2310-chunked-plan-phase.test.cjs :: source-text-is-the-product", "tests/enh-2380-sync-skills.test.cjs :: source-text-is-the-product", - "tests/enh-2415-claude-md-link-mode.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-2446-milestones-drift.test.cjs :: source-text-is-the-product", - "tests/enh-2448-artifact-registry.test.cjs :: source-text-is-the-product", "tests/enh-2500-codebase-mapper-arch-rich-format.test.cjs :: source-text-is-the-product", "tests/enh-2789-description-budget.test.cjs :: source-text-is-the-product", "tests/enh-2790-skill-consolidation.test.cjs :: source-text-is-the-product", - "tests/enh-2792-namespace-skills.test.cjs :: source-text-is-the-product", - "tests/enh-3209-plan-phase-ingest-adr.test.cjs :: source-text-is-the-product", "tests/enh-48-cwd-drift-guard-e2e.test.cjs :: integration-test-input", "tests/enh-72-business-context.test.cjs :: source-text-is-the-product", "tests/enh-770-claude-hook-events.test.cjs :: runtime-contract-is-the-product — hooks.json IS the", @@ -182,10 +152,6 @@ "tests/feat-22-surfacing-docs.test.cjs :: docs-parity", "tests/feat-2840-issue-driven-orchestration-guide.test.cjs :: structural-IR parser for a docs guide. The .includes()", "tests/feat-3039-help-tiered.test.cjs :: source-text-is-the-product", - "tests/feat-3210-fallow-integration.test.cjs :: source-text-is-the-product", - "tests/feat-3210-fallow-integration.test.cjs :: source-text-is-the-product — code-review.md IS the workflow the orchestrator", - "tests/feat-3309-human-verify-mode.test.cjs :: source-text-is-the-product", - "tests/feat-488-effort-sync.test.cjs :: structural-regression-guard — readFileSync asserts on installed agent .md files (the product under mutation) to verify dry-run safety and apply correctness; stderr.includes guards the CLI argument-rejection contract.", "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", diff --git a/scripts/lint-regression-test-names.allowlist.json b/scripts/lint-regression-test-names.allowlist.json index 4390aa567..930490711 100644 --- a/scripts/lint-regression-test-names.allowlist.json +++ b/scripts/lint-regression-test-names.allowlist.json @@ -1,17 +1,10 @@ [ - "bug-10-semver-policy-consolidation.test.cjs", "bug-131-release-tarball-smoke-explicit-home.test.cjs", "bug-1367-claude-local-flat-command-layout.test.cjs", - "bug-14-progress-auto-flag-dropped.test.cjs", - "bug-1695-state-patch-clobbers-phase-name.test.cjs", "bug-17-askuserquestion-option-cap.test.cjs", "bug-170-workflow-fallback-install-hint.test.cjs", - "bug-1829-inherit-model-profile.test.cjs", "bug-1834-sh-hooks-installed.test.cjs", "bug-1891-file-resolution.test.cjs", - "bug-190-bridge-collapse.test.cjs", - "bug-1906-hook-relative-paths.test.cjs", - "bug-1967-cache-invalidation.test.cjs", "bug-1974-context-exhaustion-record.test.cjs", "bug-2002-offer-next-context.test.cjs", "bug-2004-pr-branch-milestone.test.cjs", @@ -31,7 +24,6 @@ "bug-2410-stream-checkpoint-heartbeats.test.cjs", "bug-2419-project-researcher-agent.test.cjs", "bug-2421-planner-grep-gate-hygiene.test.cjs", - "bug-2424-reapply-patches-baseline-detection.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", @@ -43,39 +35,24 @@ "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-2530-valid-config-keys.test.cjs", "bug-2543-gsd-slash-namespace.test.cjs", "bug-2549-2550-2552-discuss-phase-context.test.cjs", - "bug-2554-decimal-phase-filter.test.cjs", - "bug-2557-gemini-local-hook-paths.test.cjs", "bug-2559-stale-search-year.test.cjs", "bug-260-worktree-path-guard.test.cjs", "bug-261-worktree-force-add-guard.test.cjs", - "bug-2638-sub-repos-canonical-location.test.cjs", - "bug-2660-one-liner-extraction.test.cjs", "bug-2686-review-fix-worktree.test.cjs", - "bug-2770-annotate-deps-int-coerce.test.cjs", "bug-2772-gitmodules-path-intersection.test.cjs", "bug-2784-update-cache-clear-path.test.cjs", - "bug-2798-context-window-config-key.test.cjs", - "bug-2801-ingest-docs-handler.test.cjs", "bug-2808-skill-hyphen-name.test.cjs", - "bug-2836-audit-open-summary-uat-drift.test.cjs", "bug-2838-summary-rescue-gitignored-planning.test.cjs", "bug-2839-review-fix-transactional-cleanup.test.cjs", - "bug-2851-workflow-bare-gsd-tools.test.cjs", "bug-2866-codex-strip-no-trailing-newline.test.cjs", "bug-2876-skill-frontmatter-quote.test.cjs", "bug-2912-progress-context-authority.test.cjs", "bug-2916-handle-branching-default-base.test.cjs", - "bug-2943-config-get-context-window-default.test.cjs", - "bug-2948-spike-wrap-up-dispatch.test.cjs", - "bug-2949-sketch-wrap-up-dispatch.test.cjs", "bug-2950-stale-command-refs.test.cjs", - "bug-2954-help-md-slash-command-stubs.test.cjs", "bug-2969-verify-reapply-patches.test.cjs", "bug-2973-profile-user-skills-path.test.cjs", - "bug-2986-config-schema-mutation-killers.test.cjs", "bug-2990-code-fixer-worktree-branch.test.cjs", "bug-2992-check-latest-version.test.cjs", "bug-2994-verify-reapply-patches-installed-path.test.cjs", @@ -89,21 +66,10 @@ "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-3126-global-skills-base-runtime-path.test.cjs", - "bug-3127-state-begin-phase-idempotent.test.cjs", - "bug-3128-roadmap-plan-count-slug-layout.test.cjs", "bug-3129-validate-commit-git-bypass.test.cjs", "bug-3130-update-npx-robust-invocation.test.cjs", - "bug-3135-capture-backlog-workflow.test.cjs", - "bug-3156-plan-phase-opencode-dispatch.test.cjs", - "bug-3168-task-to-agent-rename.test.cjs", "bug-3195-quick-resurrection-guard.test.cjs", - "bug-321-config-defaults-clone-strategy.test.cjs", - "bug-3212-execute-phase-stall-safe-resume.test.cjs", - "bug-3227-config-set-model-overrides.test.cjs", "bug-3236-capture-seed-one-shot.test.cjs", - "bug-3242-state-update-progress-trample.test.cjs", - "bug-3258-no-stale-gsd-intel-references.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", @@ -111,11 +77,7 @@ "bug-3357-codex-legacy-hooks-json-migration.test.cjs", "bug-3381-verify-work-workstream.test.cjs", "bug-3384-secondary-defects.test.cjs", - "bug-3413-shell-command-projection.test.cjs", - "bug-3418-progress-flag-routing.test.cjs", "bug-3430-planner-phase-contract.test.cjs", - "bug-3431-debug-command-yaml.test.cjs", - "bug-3441-path-action-projection.test.cjs", "bug-3442-codex-legacy-hooks-json-migration.test.cjs", "bug-3442-shim-projection-drift-guard.test.cjs", "bug-3446-resume-continue-here-discovery.test.cjs", @@ -126,59 +88,37 @@ "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", - "bug-3584-runtime-slash-formatter.test.cjs", "bug-3588-npm-audit-clean.test.cjs", "bug-3605-stale-research-insert-phase-agent-refs.test.cjs", - "bug-3608-antigravity-update-runtime-classification.test.cjs", "bug-3610-installer-migration-bundled-hooks-classification.test.cjs", "bug-3628-bundled-hook-classifier-whitelist.test.cjs", "bug-3657-verify-reapply-patches-pristine-drift.test.cjs", - "bug-3659-applysurface-prune-skill-dirs.test.cjs", "bug-3668-workflow-runtime-resolution.test.cjs", "bug-3670-cursor-local-install-migration-lock.test.cjs", "bug-3677-agent-colon-namespace-leak.test.cjs", "bug-3678-executor-commit-docs-respect.test.cjs", "bug-3683-command-colon-namespace-leak.test.cjs", - "bug-3683-command-cross-reference-invariant.test.cjs", "bug-3683-workflow-colon-namespace-leak.test.cjs", "bug-3689-resume-glob-nomatch.test.cjs", - "bug-3706-ui-safety-gate-false-positives.test.cjs", - "bug-3707-locked-worktree-cleanup.test.cjs", - "bug-3727-code-review-fix-flag-dispatch.test.cjs", "bug-3735-profiles-core-includes-surface.test.cjs", - "bug-3739-gap-checker-padded-prefix-context.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-384-agents-runtime-aware.test.cjs", "bug-444-resolver-local-claude-install.test.cjs", - "bug-447-gap-analysis-phase-req-ids.test.cjs", - "bug-474-clock-seam-date-determinism.test.cjs", - "bug-492-effort-manifest-fallback.test.cjs", - "bug-500-planned-phase-progress-corruption.test.cjs", - "bug-503-update-agent-antigravity-detection.test.cjs", "bug-571-doc-writer-fix-mode-edit-only.test.cjs", - "bug-580-local-sh-hook-bash-wrapper.test.cjs", "bug-619-codebase-drift-gate-shim.test.cjs", "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-663-redos-roadmap-phase-parsing.test.cjs", "bug-685-windowshide-spawn.test.cjs", "bug-687-agy-timeout.test.cjs", - "bug-730-milestone-phase-details-scope.test.cjs", - "bug-783-kilo-global-skills-base.test.cjs", "bug-891-non-claude-runtime-home-fallback.test.cjs", - "bug-892-validate-checklist-roadmap-phases.test.cjs", - "bug-924-claude-flat-skill-layout.test.cjs", "bug-925-context-monitor-hook-event-name.test.cjs", "bug-936-no-nested-spawner-wrap.test.cjs", "bug-941-managed-hooks-registry-manifest.test.cjs", "bug-947-hermes-gsd-prefix.test.cjs", - "bug-950-quick-summary-status-complete.test.cjs", - "bug-969-test-infra-flake-hardening.test.cjs", - "bug-974-graphify-budget-missing-value.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 c0081d941..1b5af9578 100644 --- a/scripts/lint-test-file-count.allowlist.json +++ b/scripts/lint-test-file-count.allowlist.json @@ -1,32 +1,18 @@ { "_doc": "Baseline of modules currently exceeding the 2-test-file limit. Each entry locks in TODAY's exact test filenames as the allowlisted set (identity ratchet). Adding a NEW test file to a capped module fails (novel). Removing one requires pruning this list (stale, ratchet-down). When a cluster drops to ≤ 2, remove its entry entirely. New entries require justification in PR description.", "modules": { - "audit": { - "files": [ - "audit-command-cutover.test.cjs", - "audit-fix-command.test.cjs", - "bug-2836-audit-open-summary-uat-drift.test.cjs" - ], - "issue": "192" - }, "config": { "files": [ - "bug-2943-config-get-context-window-default.test.cjs", - "bug-321-config-defaults-clone-strategy.test.cjs", - "bug-3227-config-set-model-overrides.test.cjs", "config-field-docs.test.cjs", "config-get-default.test.cjs", "config-schema.property.test.cjs", - "config.test.cjs", - "enh-1055-config-intent-descriptor-drive.test.cjs", - "fix-1628-config-set-validation.test.cjs" + "config.test.cjs" ], "issue": "TBD" }, "graphify": { "files": [ "bug-622-graphify-optional-graph-html.test.cjs", - "bug-974-graphify-budget-missing-value.test.cjs", "graphify-auto-update.slow.test.cjs", "graphify-command-cutover.test.cjs", "graphify-query.test.cjs", @@ -46,7 +32,6 @@ }, "milestone": { "files": [ - "bug-730-milestone-phase-details-scope.test.cjs", "milestone-archive.test.cjs", "milestone-helper.test.cjs", "milestone-prefixed-convention.test.cjs", @@ -65,7 +50,6 @@ }, "roadmap": { "files": [ - "bug-3128-roadmap-plan-count-slug-layout.test.cjs", "roadmap-mode-field.test.cjs", "roadmap-phase-fallback.test.cjs", "roadmap.test.cjs" @@ -91,10 +75,7 @@ }, "state": { "files": [ - "bug-1695-state-patch-clobbers-phase-name.test.cjs", "bug-21-state-md-template-frontmatter.test.cjs", - "bug-3127-state-begin-phase-idempotent.test.cjs", - "bug-3242-state-update-progress-trample.test.cjs", "state-acquirestatelock-non-eexist.test.cjs", "state-prune.test.cjs", "state-rebuild-cli.test.cjs", @@ -120,7 +101,6 @@ }, "install": { "files": [ - "enh-1082-install-plan-capstone.test.cjs", "install-minimal-hooks.test.cjs", "install-nested-layout.test.cjs", "install-path-detection.test.cjs", @@ -132,14 +112,6 @@ ], "issue": "1679" }, - "validate": { - "files": [ - "bug-3129-validate-commit-git-bypass.test.cjs", - "bug-892-validate-checklist-roadmap-phases.test.cjs", - "validate-context.test.cjs" - ], - "issue": "892" - }, "edge-probe": { "files": [ "edge-probe-docs-fixtures.test.cjs", diff --git a/tests/26-w005-w006-i001-cjs-drift-regression.test.cjs b/tests/26-w005-w006-i001-cjs-drift-regression.test.cjs deleted file mode 100644 index 34cc96b53..000000000 --- a/tests/26-w005-w006-i001-cjs-drift-regression.test.cjs +++ /dev/null @@ -1,256 +0,0 @@ -'use strict'; - -/** - * Regression tests for issue #26 (open-gsd/gsd-core). - * Three generator-pattern drift items: W005 phaseDirNameRe, - * W006-archived regex constants (PHASE_TOKEN_FROM_DIR_RE, MILESTONE_ARCHIVE_DIR_RE), - * I001 canonicalPlanStem. - * - * After the generator migration, all three helpers are sourced from - * validate.cjs. If they diverge from validate.ts, these - * tests go RED. - * - * References: - * - Issue #26 (open-gsd/gsd-core) — three drift items + reproducer - * - ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md) - * - PR #154 (issue #4) — generator pattern precedent - * - PR #156 (issue #6) — validate.ts generator scaffolding (#26 extends this) - * - Original PR #3479 — first validate.ts false-positive fix (never reached CJS) - */ - -const { describe, test, before, after } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const os = require('node:os'); - -const { runGsdTools, cleanup } = require('./helpers.cjs'); - -function mkplanning(base) { - const planningDir = path.join(base, '.planning'); - const phasesDir = path.join(planningDir, 'phases'); - fs.mkdirSync(phasesDir, { recursive: true }); - return { planningDir, phasesDir }; -} - -function writeProjectMd(planningDir) { - fs.writeFileSync( - path.join(planningDir, 'PROJECT.md'), - '# Project\n\n## What This Is\nTest.\n\n## Core Value\nTest.\n\n## Requirements\nTest.\n', - ); -} - -function writeStateMd(planningDir, phase) { - fs.writeFileSync( - path.join(planningDir, 'STATE.md'), - `# State\n\n**Current Phase:** ${phase}\n**Status:** In progress\n`, - ); -} - -function writeConfigJson(planningDir) { - fs.writeFileSync( - path.join(planningDir, 'config.json'), - JSON.stringify({ model_profile: 'balanced' }), - ); -} - -// ── Drift Item W005: phaseDirNameRe ────────────────────────────────────────── -// -// Issue #26 reproducer (verbatim): -// mkdir -p .planning/phases/999.1-foo -// echo "# Roadmap" > .planning/ROADMAP.md -// node .claude/gsd-core/bin/gsd-tools.cjs validate health -// # Bug: emits W005 about 999.1-foo not following NN-name format -// -// verify.cjs must consume phaseDirNameRe from validate.cjs so -// the regex /^\d{2,}(?:\.\d+)*-[\w-]+$/ is the single source of truth. - -describe('Drift item W005 — phaseDirNameRe: 999.X-name dirs must not trigger W005', () => { - let tmpDir; - - before(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-26-d1-')); - const { planningDir, phasesDir } = mkplanning(tmpDir); - writeProjectMd(planningDir); - writeStateMd(planningDir, '999.1'); - writeConfigJson(planningDir); - - fs.writeFileSync( - path.join(planningDir, 'ROADMAP.md'), - '# Roadmap\n\n- [x] **Phase 999.1:** Long Phase\n\n### Phase 999.1: Long Phase\n', - ); - - // Exact reproducer from issue #26 - fs.mkdirSync(path.join(phasesDir, '999.1-foo'), { recursive: true }); - }); - - after(() => { cleanup(tmpDir); }); - - test('no W005 for 999.1-foo (multi-digit sub-phase prefix)', () => { - const result = runGsdTools(['validate', 'health', '--json'], tmpDir); - assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`); - const data = JSON.parse(result.output); - const w005 = (data.warnings ?? []).filter((w) => w.code === 'W005'); - assert.strictEqual(w005.length, 0, - `Expected zero W005 for 999.1-foo, got: ${JSON.stringify(w005)}`); - }); - - test('phaseDirNameRe is exported from validate.cjs', () => { - const gen = require('../gsd-core/bin/lib/validate.cjs'); - assert.ok(gen.phaseDirNameRe instanceof RegExp, - 'validate.cjs must export phaseDirNameRe as a RegExp'); - const re = gen.phaseDirNameRe; - assert.ok(re.test('01-setup'), 'should accept 01-setup'); - assert.ok(re.test('999-longphase'), 'should accept 999-longphase (3-digit prefix)'); - assert.ok(re.test('999.1-foo'), 'should accept 999.1-foo (sub-phase)'); - assert.ok(re.test('MANIFOLD-999.1-foo'), 'should accept long project-code prefixes'); - assert.ok(re.test('APP1-999.1-foo'), 'should accept numeric characters in project-code prefixes'); - assert.ok(re.test('APP_1-999.1-foo'), 'should accept underscore characters in project-code prefixes'); - assert.ok(!re.test('1-shortname'), 'should reject single-digit prefix'); - }); -}); - -// ── Drift Item W006-archived: PHASE_TOKEN_FROM_DIR_RE / MILESTONE_ARCHIVE_DIR_RE ─ -// -// forEachArchivedPhaseToken() in verify.cjs uses two inline regex constants. -// After migration both are sourced from validate.cjs: -// PHASE_TOKEN_FROM_DIR_RE — extracts token from dir name like "64-auth-service" -// MILESTONE_ARCHIVE_DIR_RE — matches archive dirs like "v1.0-phases" -// -// Test: phase 64 was archived to milestones/v1.0-phases/64-auth-service/. -// Without correct archive detection, W006 fires for "Phase 64 in ROADMAP.md -// but no directory on disk". - -describe('Drift item W006-archived — MILESTONE_ARCHIVE_DIR_RE and PHASE_TOKEN_FROM_DIR_RE', () => { - let tmpDir; - - before(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-26-d2-')); - const { planningDir, phasesDir } = mkplanning(tmpDir); - writeProjectMd(planningDir); - writeStateMd(planningDir, '65'); - writeConfigJson(planningDir); - - fs.writeFileSync( - path.join(planningDir, 'ROADMAP.md'), - [ - '# Roadmap', - '', - '- [x] **Phase 65:** Current Work', - '', - '### Phase 65: Current Work', - '', - '
', - 'Milestone v1.0 — Shipped', - '', - '### Phase 64: Auth Service', - '', - '
', - '', - ].join('\n'), - ); - - // Active phasesDir: only phase 65 - fs.mkdirSync(path.join(phasesDir, '65-current-work'), { recursive: true }); - - // Archive: milestones/v1.0-phases/64-auth-service - // MILESTONE_ARCHIVE_DIR_RE must match "v1.0-phases" - // PHASE_TOKEN_FROM_DIR_RE must extract "64" from "64-auth-service" - fs.mkdirSync( - path.join(planningDir, 'milestones', 'v1.0-phases', '64-auth-service'), - { recursive: true }, - ); - }); - - after(() => { cleanup(tmpDir); }); - - test('no W006 for Phase 64 archived under milestones/v1.0-phases/', () => { - const result = runGsdTools(['validate', 'health', '--json'], tmpDir); - assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`); - const data = JSON.parse(result.output); - const w006 = (data.warnings ?? []).filter( - (w) => w.code === 'W006' && /Phase 64/i.test(w.message), - ); - assert.strictEqual(w006.length, 0, - `Expected no W006 for archived Phase 64, got: ${JSON.stringify(w006)}`); - }); - - test('MILESTONE_ARCHIVE_DIR_RE is exported and matches vN.N-phases dirs', () => { - const gen = require('../gsd-core/bin/lib/validate.cjs'); - assert.ok(gen.MILESTONE_ARCHIVE_DIR_RE instanceof RegExp, - 'validate.cjs must export MILESTONE_ARCHIVE_DIR_RE'); - const re = gen.MILESTONE_ARCHIVE_DIR_RE; - assert.ok(re.test('v1.0-phases'), 'should match v1.0-phases'); - assert.ok(re.test('v1.10-phases'), 'should match v1.10-phases'); - assert.ok(!re.test('phases'), 'should NOT match plain phases'); - assert.ok(!re.test('1.0-phases'), 'should NOT match missing v prefix'); - }); - - test('PHASE_TOKEN_FROM_DIR_RE is exported and extracts phase tokens correctly', () => { - const gen = require('../gsd-core/bin/lib/validate.cjs'); - assert.ok(gen.PHASE_TOKEN_FROM_DIR_RE instanceof RegExp, - 'validate.cjs must export PHASE_TOKEN_FROM_DIR_RE'); - const re = gen.PHASE_TOKEN_FROM_DIR_RE; - assert.strictEqual(re.exec('64-auth-service')?.[1], '64'); - assert.strictEqual(re.exec('03B-feature')?.[1], '03B'); - assert.strictEqual(re.exec('999.1-foo')?.[1], '999.1'); - assert.strictEqual(re.exec('CK-64-auth')?.[1], '64'); - assert.strictEqual(re.exec('MANIFOLD-64-auth')?.[1], '64'); - assert.strictEqual(re.exec('APP1-64-auth')?.[1], '64'); - assert.strictEqual(re.exec('APP_1-64-auth')?.[1], '64'); - }); -}); - -// ── Drift Item I001: canonicalPlanStem ──────────────────────────────────────── -// -// validate.ts Check 7: canonicalPlanStem('68-01-scaffolding') → '68-01' -// verify.cjs had an inline copy. After migration, canonicalPlanStem is -// sourced from validate.cjs. -// -// Test: "68-01-scaffolding-PLAN.md" + "68-01-SUMMARY.md" → no I001 -// Both stems canonicalize to "68-01" → match found → I001 suppressed. - -describe('Drift item I001 — canonicalPlanStem: long PLAN stem matches short SUMMARY stem', () => { - let tmpDir; - - before(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-26-d3-')); - const { planningDir, phasesDir } = mkplanning(tmpDir); - writeProjectMd(planningDir); - writeStateMd(planningDir, '68'); - writeConfigJson(planningDir); - - fs.writeFileSync( - path.join(planningDir, 'ROADMAP.md'), - '# Roadmap\n\n- [x] **Phase 68:** Scaffolding\n\n### Phase 68: Scaffolding\n', - ); - - const phaseDir = path.join(phasesDir, '68-scaffolding'); - fs.mkdirSync(phaseDir, { recursive: true }); - - // Long-stem PLAN + short-stem SUMMARY → must match via canonicalPlanStem - fs.writeFileSync(path.join(phaseDir, '68-01-scaffolding-PLAN.md'), '---\nwave: 1\n---\n# Plan\n'); - fs.writeFileSync(path.join(phaseDir, '68-01-SUMMARY.md'), '# Summary\n'); - }); - - after(() => { cleanup(tmpDir); }); - - test('no I001 when 68-01-scaffolding-PLAN.md matches 68-01-SUMMARY.md via canonicalPlanStem', () => { - const result = runGsdTools(['validate', 'health', '--json'], tmpDir); - assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`); - const data = JSON.parse(result.output); - const i001 = (data.info ?? []).filter((i) => i.code === 'I001'); - assert.strictEqual(i001.length, 0, - `Expected zero I001, got: ${JSON.stringify(i001)}`); - }); - - test('canonicalPlanStem is exported from validate.cjs', () => { - const gen = require('../gsd-core/bin/lib/validate.cjs'); - assert.strictEqual(typeof gen.canonicalPlanStem, 'function', - 'validate.cjs must export canonicalPlanStem as a function'); - assert.strictEqual(gen.canonicalPlanStem('68-01-scaffolding'), '68-01'); - assert.strictEqual(gen.canonicalPlanStem('68-01'), '68-01'); - assert.strictEqual(gen.canonicalPlanStem('3A-01-feature'), '3A-01'); - assert.strictEqual(gen.canonicalPlanStem('no-match'), 'no-match'); - }); -}); diff --git a/tests/4-phase-complete-cjs-regression.test.cjs b/tests/4-phase-complete-cjs-regression.test.cjs deleted file mode 100644 index 709e0aa64..000000000 --- a/tests/4-phase-complete-cjs-regression.test.cjs +++ /dev/null @@ -1,1099 +0,0 @@ -'use strict'; - -/** - * Regression test for issue #4 (open-gsd/gsd-core): - * bin/lib/phase.cjs cmdPhaseComplete — non-idempotent and unclamped. - * - * Root cause (pre-fix): - * cmdPhaseComplete blindly increments "Completed Phases" by 1 on every call - * (parseInt(completedRaw, 10) + 1) and recomputes Progress without a clamp. - * Double-calling yields Completed Phases +2 (not +1), and Progress can exceed 100%. - * - * Fix: derive completed_phases from ROADMAP Complete-row count (idempotent) and - * clamp percent to 100. This mirrors the SDK fix in phase-lifecycle.ts - * (commit deriving from ROADMAP, referenced as "PR #3520" in issue #4). - * - * Note on test structure: phase-command-router.cjs delegates to the SDK when - * the SDK dist is present, so the CJS path is exercised by calling - * cmdPhaseComplete directly (bypassing the router), which is the actual - * function containing the bug. - * - * References: - * - ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md) — architectural foundation - * - /tmp/adr-3524-review-findings.md — architectural justification - * - Issue #4 (open-gsd/gsd-core) - */ - -const { describe, test, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const os = require('node:os'); - -const { cleanup, runGsdTools } = require('./helpers.cjs'); - -// ── Load cmdPhaseComplete directly from phase.cjs (bypass the SDK router) ──── -// phase-command-router.cjs delegates to SDK when available; we must test the -// CJS implementation directly since that is where the bug lives. -const phaseModule = require('../gsd-core/bin/lib/phase.cjs'); -const { cmdPhaseComplete } = phaseModule; - -function writePassedVerificationFile(phaseDir, phase = '01') { - fs.writeFileSync(path.join(phaseDir, `${phase}-VERIFICATION.md`), [ - '---', - 'status: passed', - '---', - '', - '# Verification', - '', - ].join('\n')); -} - -// ── Fixture builder ────────────────────────────────────────────────────────── - -/** - * Creates a minimal fixture project with: - * - ROADMAP.md with a 4-column progress table (Phase | Plans | Status | Completed) - * - REQUIREMENTS.md with a phase-scoped REQ-ID and Traceability row - * - STATE.md with Completed Phases: 0 and Total Phases: 2 (Progress 0%) - * - Phase 01 directory with one plan+summary (to satisfy phase complete guard) - * - Phase 02 directory (next phase) - */ -function createFixture(prefix = 'gsd-4-regression-') { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); - const planningDir = path.join(tmpDir, '.planning'); - const phasesDir = path.join(planningDir, 'phases'); - fs.mkdirSync(phasesDir, { recursive: true }); - - // ROADMAP.md: Phase 01 not yet complete, Phase 02 not started - // 4-column progress table: Phase | Plans Complete | Status | Completed - const roadmap = [ - '# Roadmap', - '', - '- [ ] Phase 01: Foundation', - '- [ ] Phase 02: API', - '', - '### Phase 01: Foundation', - '**Goal:** Build the foundation', - '**Requirements:** REQ-1', - '**Plans:** 1 plans', - '', - '### Phase 02: API', - '**Goal:** Build the API', - '', - '## Progress', - '', - '| Phase | Plans Complete | Status | Completed |', - '|-------|----------------|--------|-----------|', - '| 01. Foundation | 0/1 | Not started | - |', - '| 02. API | 0/1 | Not started | - |', - '', - ].join('\n'); - fs.writeFileSync(path.join(planningDir, 'ROADMAP.md'), roadmap); - - const requirements = [ - '# Requirements', - '', - '## Functional Requirements', - '', - '- [ ] **REQ-1** Foundation must be complete.', - '', - '## Traceability', - '', - '| Requirement | Phase | Status |', - '|-------------|-------|--------|', - '| REQ-1 | Phase 01 | Pending |', - '', - ].join('\n'); - fs.writeFileSync(path.join(planningDir, 'REQUIREMENTS.md'), requirements); - - // STATE.md: Completed Phases: 0, Total Phases: 2, Progress: 0% - // Uses body-field format (bold **Field:** value) so the CJS handler's - // stateExtractField/stateReplaceField path is exercised. - const state = [ - '# State', - '', - '**Current Phase:** 01', - '**Current Phase Name:** Foundation', - '**Status:** In progress', - '**Current Plan:** 01-01', - '**Last Activity:** 2025-01-01', - '**Last Activity Description:** Working on phase 1', - '**Completed Phases:** 0', - '**Total Phases:** 2', - '**Progress:** 0%', - '', - ].join('\n'); - fs.writeFileSync(path.join(planningDir, 'STATE.md'), state); - - // Phase 01 directory with a PLAN and SUMMARY so phase complete guard passes - const phase01Dir = path.join(phasesDir, '01-foundation'); - fs.mkdirSync(phase01Dir, { recursive: true }); - fs.writeFileSync(path.join(phase01Dir, '01-01-PLAN.md'), '# Plan 1\nDo the work.\n'); - fs.writeFileSync(path.join(phase01Dir, '01-01-SUMMARY.md'), '# Summary 1\nDone.\n'); - writePassedVerificationFile(phase01Dir); - - // Phase 02 directory (needed for "next phase" detection) - fs.mkdirSync(path.join(phasesDir, '02-api'), { recursive: true }); - - return tmpDir; -} - -function readStateMd(tmpDir) { - return fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf8'); -} - -function roadmapCompletionSnapshot(roadmapContent) { - const snapshot = { - phaseCheckboxes: [], - progressRows: [], - }; - - for (const line of roadmapContent.split(/\r?\n/)) { - let match = line.match(/^- \[([ x])\] Phase ([^:]+): (.*)$/); - if (match) { - snapshot.phaseCheckboxes.push({ - checked: match[1] === 'x', - phase: match[2].trim(), - title: match[3].replace(/\s+\(completed [^)]+\)$/, '').trim(), - }); - continue; - } - - match = line.match(/^\|\s*(\d+[A-Z]?(?:\.\d+)*)\.?\s*([^|]*)\|\s*([^|]*)\|\s*([^|]*)\|\s*([^|]*)\|$/i); - if (match) { - snapshot.progressRows.push({ - phase: match[1].trim(), - title: match[2].trim(), - plans: match[3].trim(), - status: match[4].trim(), - completed: match[5].trim(), - }); - } - } - - return snapshot; -} - -function extractField(stateContent, fieldName) { - const escaped = fieldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const boldMatch = stateContent.match(new RegExp(`\\*\\*${escaped}:\\*\\*[ \\t]*(.+)`, 'i')); - if (boldMatch) return boldMatch[1].trim(); - const plainMatch = stateContent.match(new RegExp(`^${escaped}:[ \\t]*(.+)`, 'im')); - return plainMatch ? plainMatch[1].trim() : null; -} - -function extractFrontmatterField(stateContent, fieldName) { - // Extract from YAML frontmatter block - const fmMatch = stateContent.match(/^---\r?\n([\s\S]*?)\r?\n---/); - if (!fmMatch) return null; - const fm = fmMatch[1]; - // Handle both scalar and nested (progress.completed_phases) - const parts = fieldName.split('.'); - if (parts.length === 1) { - const m = fm.match(new RegExp(`^${parts[0]}:\\s*(.+)`, 'm')); - return m ? m[1].trim() : null; - } - // Nested: e.g. "progress.completed_phases" - const sectionMatch = fm.match(new RegExp(`^${parts[0]}:\\s*\\n([\\s\\S]*?)(?=\\n[a-z]|$)`, 'm')); - if (!sectionMatch) return null; - const sectionContent = sectionMatch[1]; - const fieldMatch = sectionContent.match(new RegExp(`^\\s+${parts[1]}:\\s*(.+)`, 'm')); - return fieldMatch ? fieldMatch[1].trim() : null; -} - -// Capture stdout from cmdPhaseComplete (it calls output() which writes to stdout) -function capturePhaseComplete(cwd, phaseNum) { - // We invoke gsd-tools directly for the full CJS path, but with GSD_DISABLE_SDK_BRIDGE=1 - // to force the CJS implementation. Since no env var disables bridge, we call cmdPhaseComplete - // directly and redirect output capture. - const chunks = []; - const origWrite = process.stdout.write.bind(process.stdout); - const origErrWrite = process.stderr.write.bind(process.stderr); - process.stdout.write = (chunk) => { chunks.push(chunk); return true; }; - process.stderr.write = () => true; - try { - cmdPhaseComplete(cwd, phaseNum, false); - } finally { - process.stdout.write = origWrite; - process.stderr.write = origErrWrite; - } - return chunks.join(''); -} - -// ── T1: Double invocation must NOT double-increment Completed Phases ───────── - -describe('issue #4 (CJS): cmdPhaseComplete — idempotency (blind-increment bug)', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createFixture(); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('T1: double invocation does NOT double-increment Completed Phases in STATE.md body', () => { - // First call — legitimate completion - capturePhaseComplete(tmpDir, '1'); - - const stateAfter1 = readStateMd(tmpDir); - const completedAfter1Body = extractField(stateAfter1, 'Completed Phases'); - const completedAfter1Fm = extractFrontmatterField(stateAfter1, 'progress.completed_phases'); - // After first call: Completed Phases in the body should be 1 - // (derived from ROADMAP: 1 Complete row after marking phase 01 complete) - // OR the YAML frontmatter completed_phases should be 1 - const completedAfter1 = completedAfter1Body || completedAfter1Fm; - assert.equal( - completedAfter1, - '1', - `After first call: completed_phases should be 1.\n` + - `Body field: ${completedAfter1Body}, FM field: ${completedAfter1Fm}\n\n` + - `STATE:\n${stateAfter1}`, - ); - - // Second call on the same phase — must be idempotent - capturePhaseComplete(tmpDir, '1'); - - const stateAfter2 = readStateMd(tmpDir); - const completedAfter2Body = extractField(stateAfter2, 'Completed Phases'); - const completedAfter2Fm = extractFrontmatterField(stateAfter2, 'progress.completed_phases'); - const completedAfter2 = completedAfter2Body || completedAfter2Fm; - - // Pre-fix: completedAfter2 would be "2" (blind increment: 1+1=2) - // Post-fix: must remain "1" (derived from ROADMAP) - assert.equal( - completedAfter2, - '1', - `T1 FAILED: Completed Phases was double-incremented.\n` + - `After first call: ${completedAfter1}, after second call: ${completedAfter2}.\n` + - `This is the #4 non-idempotency bug — blind parseInt+1 instead of deriving from ROADMAP.\n\n` + - `STATE after second call (body: ${completedAfter2Body}, fm: ${completedAfter2Fm}):\n${stateAfter2}`, - ); - }); - - test('rolls back ROADMAP when STATE write fails during phase completion', (t) => { - const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); - const reqPath = path.join(tmpDir, '.planning', 'REQUIREMENTS.md'); - const statePath = path.join(tmpDir, '.planning', 'STATE.md'); - const originalRoadmap = fs.readFileSync(roadmapPath, 'utf8'); - const originalReq = fs.readFileSync(reqPath, 'utf8'); - const originalState = fs.readFileSync(statePath, 'utf8'); - const originalWriteFileSync = fs.writeFileSync; - - t.mock.method(fs, 'writeFileSync', function injectedStateWriteFailure(target, ...args) { - const targetPath = String(target); - const isStatePublish = targetPath === statePath || targetPath === `${statePath}.tmp.${process.pid}`; - if (isStatePublish) { - const err = new Error('injected STATE.md write failure'); - err.code = 'EIO'; - throw err; - } - return originalWriteFileSync.call(this, target, ...args); - }); - - assert.throws( - () => capturePhaseComplete(tmpDir, '1'), - /injected STATE\.md write failure/, - ); - - const roadmapAfter = fs.readFileSync(roadmapPath, 'utf8'); - const reqAfter = fs.readFileSync(reqPath, 'utf8'); - const stateAfter = fs.readFileSync(statePath, 'utf8'); - - assert.deepEqual( - roadmapCompletionSnapshot(roadmapAfter), - roadmapCompletionSnapshot(originalRoadmap), - 'ROADMAP.md should roll back to its original completion state', - ); - assert.equal(reqAfter, originalReq, 'REQUIREMENTS.md should roll back when STATE.md write fails'); - assert.equal(stateAfter, originalState, 'STATE.md should remain unchanged after injected write failure'); - }); - - test('rolls back ROADMAP when REQUIREMENTS write fails during phase completion', (t) => { - const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); - const reqPath = path.join(tmpDir, '.planning', 'REQUIREMENTS.md'); - const originalRoadmap = fs.readFileSync(roadmapPath, 'utf8'); - const originalReq = fs.readFileSync(reqPath, 'utf8'); - const originalWriteFileSync = fs.writeFileSync; - - t.mock.method(fs, 'writeFileSync', function injectedRequirementsWriteFailure(target, ...args) { - const targetPath = String(target); - if (targetPath === reqPath || targetPath === `${reqPath}.tmp.${process.pid}`) { - const err = new Error('injected REQUIREMENTS.md write failure'); - err.code = 'EIO'; - throw err; - } - return originalWriteFileSync.call(this, target, ...args); - }); - - assert.throws( - () => capturePhaseComplete(tmpDir, '1'), - /injected REQUIREMENTS\.md write failure/, - ); - - assert.deepEqual( - roadmapCompletionSnapshot(fs.readFileSync(roadmapPath, 'utf8')), - roadmapCompletionSnapshot(originalRoadmap), - 'ROADMAP.md should roll back when the REQUIREMENTS write fails', - ); - assert.equal(fs.readFileSync(reqPath, 'utf8'), originalReq, 'REQUIREMENTS.md should be unchanged'); - }); - - test('reports rollback failure when restoring an earlier planning file fails', (t) => { - const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); - const reqPath = path.join(tmpDir, '.planning', 'REQUIREMENTS.md'); - const originalWriteFileSync = fs.writeFileSync; - let requirementsWriteFailed = false; - - t.mock.method(fs, 'writeFileSync', function injectedRollbackFailure(target, ...args) { - const targetPath = String(target); - if (targetPath === reqPath || targetPath === `${reqPath}.tmp.${process.pid}`) { - requirementsWriteFailed = true; - const err = new Error('injected REQUIREMENTS.md write failure'); - err.code = 'EIO'; - throw err; - } - if (requirementsWriteFailed && (targetPath === roadmapPath || targetPath === `${roadmapPath}.tmp.${process.pid}`)) { - const err = new Error('injected ROADMAP.md rollback failure'); - err.code = 'EIO'; - throw err; - } - return originalWriteFileSync.call(this, target, ...args); - }); - - assert.throws( - () => capturePhaseComplete(tmpDir, '1'), - /injected REQUIREMENTS\.md write failure[\s\S]*WARNING: rollback failed while restoring[\s\S]*injected ROADMAP\.md rollback failure/, - ); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Regressions: phase complete preserves completion date (#1161) -// Tests drive the REAL handler (cmdPhaseComplete) via the CLI entry point -// `runGsdTools('phase complete ')` so the fix in phase.cts is exercised -// end-to-end rather than hitting the roadmap.cjs helper in isolation. -// ───────────────────────────────────────────────────────────────────────────── - -/** Extract the Completed cell from the progress table row for a given phase number. - * The Completed column is always the LAST cell, regardless of whether the table is - * 4-col (Phase | Plans | Status | Completed) or 5-col (Phase | Milestone | Plans | Status | Completed). - */ -function extractCompletedCell(roadmapContent, phaseNum) { - // Match the full progress table row whose first cell starts with the phase number. - // Use [^|\n] to avoid crossing line boundaries. Capture everything up to the final '|'. - const re = new RegExp(`^(\\|\\s*${phaseNum}[^|\\n]*(?:\\|[^|\\n]*)*)\\|\\s*$`, 'm'); - const m = roadmapContent.match(re); - if (!m) return null; - // m[1] = '| 01. Foundation | 1/1 | Complete | 2026-01-01 ' - // Split on '|' → ['', ' 01. Foundation ', ' 1/1 ', ' Complete ', ' 2026-01-01 '] - // Drop the leading empty string and take the last element. - const cells = m[1].split('|').slice(1); // drop leading '' - return cells[cells.length - 1].trim(); -} - -/** - * Build a minimal 4-col ROADMAP project fixture whose Phase 01 row already has - * the Completed cell set to `existingDate` and Status `Complete`. - * The phase directory has plan+summary so `phase complete 1` can run. - * - * @param {string} existingDate - value in the Completed cell ('2026-01-01', '-', ' ', etc.) - * @param {boolean} [alreadyComplete] - if true the checkbox is already checked and status Complete - */ -function create4ColFixture(existingDate, alreadyComplete = true) { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1161-4col-')); - const planDir = path.join(tmpDir, '.planning'); - const phasesDir = path.join(planDir, 'phases'); - fs.mkdirSync(phasesDir, { recursive: true }); - - const checkbox = alreadyComplete ? '[x]' : '[ ]'; - const checkboxSuffix = alreadyComplete ? ' (completed 2026-01-01)' : ''; - const status = alreadyComplete ? 'Complete ' : 'Not started'; - - const roadmap = [ - '# Roadmap', - '', - `- ${checkbox} Phase 01: Foundation${checkboxSuffix}`, - '- [ ] Phase 02: API', - '', - '### Phase 01: Foundation', - '**Goal:** Build the foundation', - '**Plans:** 1/1 plans complete', - '', - '### Phase 02: API', - '**Goal:** Build the API', - '', - '## Progress', - '', - '| Phase | Plans Complete | Status | Completed |', - '|-------|----------------|--------|-----------|', - `| 01. Foundation | 1/1 | ${status} | ${existingDate} |`, - '| 02. API | 0/1 | Not started | - |', - '', - ].join('\n'); - fs.writeFileSync(path.join(planDir, 'ROADMAP.md'), roadmap); - - const state = [ - '# State', - '', - '**Current Phase:** 01', - '**Current Phase Name:** Foundation', - '**Status:** In progress', - '**Current Plan:** 01-01', - '**Last Activity:** 2025-01-01', - '**Last Activity Description:** Working on phase 1', - '**Completed Phases:** 0', - '**Total Phases:** 2', - '**Progress:** 0%', - '', - ].join('\n'); - fs.writeFileSync(path.join(planDir, 'STATE.md'), state); - - const phase01Dir = path.join(phasesDir, '01-foundation'); - fs.mkdirSync(phase01Dir, { recursive: true }); - fs.writeFileSync(path.join(phase01Dir, '01-01-PLAN.md'), '# Plan 1\nDo the work.\n'); - fs.writeFileSync(path.join(phase01Dir, '01-01-SUMMARY.md'), '# Summary 1\nDone.\n'); - writePassedVerificationFile(phase01Dir); - - fs.mkdirSync(path.join(phasesDir, '02-api'), { recursive: true }); - - return tmpDir; -} - -/** - * Build a minimal 5-col ROADMAP project fixture (Phase | Milestone | Plans | Status | Completed). - * Phase 01 row already has Completed cell set to `existingDate`. - */ -function create5ColFixture(existingDate, alreadyComplete = true) { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1161-5col-')); - const planDir = path.join(tmpDir, '.planning'); - const phasesDir = path.join(planDir, 'phases'); - fs.mkdirSync(phasesDir, { recursive: true }); - - const checkbox = alreadyComplete ? '[x]' : '[ ]'; - const checkboxSuffix = alreadyComplete ? ' (completed 2026-01-01)' : ''; - const status = alreadyComplete ? 'Complete ' : 'Not started'; - - const roadmap = [ - '# Roadmap', - '', - `- ${checkbox} Phase 01: Foundation${checkboxSuffix}`, - '- [ ] Phase 02: API', - '', - '### Phase 01: Foundation', - '**Goal:** Build the foundation', - '**Plans:** 1/1 plans complete', - '', - '### Phase 02: API', - '**Goal:** Build the API', - '', - '## Progress', - '', - '| Phase | Milestone | Plans | Status | Completed |', - '|-------|-----------|-------|--------|-----------|', - `| 01. Foundation | v1.0 | 1/1 | ${status} | ${existingDate} |`, - '| 02. API | v1.0 | 0/1 | Not started | - |', - '', - ].join('\n'); - fs.writeFileSync(path.join(planDir, 'ROADMAP.md'), roadmap); - - const state = [ - '# State', - '', - '**Current Phase:** 01', - '**Current Phase Name:** Foundation', - '**Status:** In progress', - '**Current Plan:** 01-01', - '**Last Activity:** 2025-01-01', - '**Last Activity Description:** Working on phase 1', - '**Completed Phases:** 0', - '**Total Phases:** 2', - '**Progress:** 0%', - '', - ].join('\n'); - fs.writeFileSync(path.join(planDir, 'STATE.md'), state); - - const phase01Dir = path.join(phasesDir, '01-foundation'); - fs.mkdirSync(phase01Dir, { recursive: true }); - fs.writeFileSync(path.join(phase01Dir, '01-01-PLAN.md'), '# Plan 1\nDo the work.\n'); - fs.writeFileSync(path.join(phase01Dir, '01-01-SUMMARY.md'), '# Summary 1\nDone.\n'); - writePassedVerificationFile(phase01Dir); - - fs.mkdirSync(path.join(phasesDir, '02-api'), { recursive: true }); - - return tmpDir; -} - -// Fixed historical instant — will never collide with a real today() in CI. -const PINNED_MS_1161 = Date.parse('2021-03-22T10:00:00.000Z'); -const PINNED_DATE_1161 = '2021-03-22'; -// Env passed to runGsdTools to pin the clock in the subprocess SUT. -const PINNED_CLOCK_ENV = { - GSD_TEST_MODE: '1', - GSD_NOW_MS: String(PINNED_MS_1161), -}; - -describe('regressions: phase complete preserves completion date (#1161)', () => { - let tmpDir; - - afterEach(() => { - cleanup(tmpDir); - }); - - // ── (a) 4-col: already Complete with a date — repeat phase complete must NOT overwrite ── - - test('#1161 (a): 4-col ROADMAP — repeat `phase complete 1` preserves existing Completed date', () => { - // Arrange: Row is already Complete with '2026-01-01'. - tmpDir = create4ColFixture('2026-01-01', true); - const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); - - // Act: run `phase complete 1` via the real CLI handler, clock pinned to PINNED_DATE. - const result = runGsdTools('phase complete 1', tmpDir, PINNED_CLOCK_ENV); - assert.ok(result.success, `phase complete failed: ${result.error || result.output}`); - - // Assert: Completed cell must still be '2026-01-01', NOT the pinned '2021-03-22'. - const after = fs.readFileSync(roadmapPath, 'utf8'); - const completedCell = extractCompletedCell(after, '01'); - assert.strictEqual( - completedCell, - '2026-01-01', - `#1161 (a) FAILED: repeat phase complete on 4-col table overwrote the existing date.\n` + - `Expected '2026-01-01', got '${completedCell}'.\n` + - `Pinned clock was '${PINNED_DATE_1161}' — if that appears the date was overwritten.\n\n` + - `ROADMAP after:\n${after}`, - ); - }); - - // ── (b) 5-col: already Complete with a date — repeat phase complete must NOT overwrite ── - - test('#1161 (b): 5-col ROADMAP — repeat `phase complete 1` preserves existing Completed date', () => { - // Arrange: 5-col table row is already Complete with '2026-01-01'. - tmpDir = create5ColFixture('2026-01-01', true); - const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); - - // Act: run `phase complete 1` via the real CLI handler, clock pinned to PINNED_DATE. - const result = runGsdTools('phase complete 1', tmpDir, PINNED_CLOCK_ENV); - assert.ok(result.success, `phase complete failed: ${result.error || result.output}`); - - // Assert: Completed cell must still be '2026-01-01', NOT the pinned '2021-03-22'. - const after = fs.readFileSync(roadmapPath, 'utf8'); - const completedCell5 = extractCompletedCell(after, '01'); - assert.strictEqual( - completedCell5, - '2026-01-01', - `#1161 (b) FAILED: repeat phase complete on 5-col table overwrote the existing date.\n` + - `Expected '2026-01-01', got '${completedCell5}'.\n` + - `Pinned clock was '${PINNED_DATE_1161}' — if that appears the date was overwritten.\n\n` + - `ROADMAP after:\n${after}`, - ); - }); - - // ── (c) First-time completion (placeholder '-') must stamp the pinned date ── - - test('#1161 (c): 4-col ROADMAP — first `phase complete 1` (placeholder date) stamps pinned date', () => { - // Arrange: Row has '-' as Completed cell and is Not started (never completed). - tmpDir = create4ColFixture('-', false); - const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); - - // Act: first-time phase complete. - const result = runGsdTools('phase complete 1', tmpDir, PINNED_CLOCK_ENV); - assert.ok(result.success, `phase complete failed: ${result.error || result.output}`); - - // Assert: Completed cell is now the pinned date. - const after = fs.readFileSync(roadmapPath, 'utf8'); - const completedCell = extractCompletedCell(after, '01'); - assert.strictEqual( - completedCell, - PINNED_DATE_1161, - `#1161 (c) FAILED: first-time completion should stamp '${PINNED_DATE_1161}', got '${completedCell}'.\n\n` + - `ROADMAP after:\n${after}`, - ); - }); - - // ── (d) Whitespace-only Completed cell is treated as empty and gets stamped ── - - test('#1161 (d): 4-col ROADMAP — whitespace-only Completed cell treated as empty, gets stamped', () => { - // Arrange: Row has ' ' (spaces) as Completed cell. - tmpDir = create4ColFixture(' ', false); - const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); - - // Act: first-time phase complete. - const result = runGsdTools('phase complete 1', tmpDir, PINNED_CLOCK_ENV); - assert.ok(result.success, `phase complete failed: ${result.error || result.output}`); - - // Assert: Completed cell is now the pinned date (whitespace was treated as empty). - const after = fs.readFileSync(roadmapPath, 'utf8'); - const completedCell = extractCompletedCell(after, '01'); - assert.strictEqual( - completedCell, - PINNED_DATE_1161, - `#1161 (d) FAILED: whitespace-only Completed cell should be stamped '${PINNED_DATE_1161}', got '${completedCell}'.\n\n` + - `ROADMAP after:\n${after}`, - ); - }); - - // ── (e) Non-date garbage in Completed cell is self-healed and gets re-stamped ── - - test('#1161 (e): 5-col ROADMAP — non-date garbage Completed cell is self-healed and re-stamped', () => { - // Arrange: 5-col row is already Complete but the Completed cell contains 'TBD' - // (a non-date garbage value that the old guard would have preserved). - tmpDir = create5ColFixture('TBD', true); - const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); - - // Act: run `phase complete 1` via the real CLI handler, clock pinned to PINNED_DATE. - const result = runGsdTools('phase complete 1', tmpDir, PINNED_CLOCK_ENV); - assert.ok(result.success, `phase complete failed: ${result.error || result.output}`); - - // Assert: garbage 'TBD' must be replaced with the pinned date (self-heal). - // Pre-Fix 2: the old guard (existingDate && existingDate !== '-') would preserve 'TBD'. - // Post-Fix 2: the date-shape guard (/^\d{4}-\d{2}-\d{2}$/) rejects 'TBD' → re-stamps. - const after = fs.readFileSync(roadmapPath, 'utf8'); - const completedCell = extractCompletedCell(after, '01'); - assert.strictEqual( - completedCell, - PINNED_DATE_1161, - `#1161 (e) FAILED: non-date garbage 'TBD' in Completed cell should be self-healed to '${PINNED_DATE_1161}', got '${completedCell}'.\n` + - `Old guard (non-empty && !== '-') would preserve 'TBD'. New guard must require a date shape.\n\n` + - `ROADMAP after:\n${after}`, - ); - }); -}); - -// ── T2: Progress percent must never exceed 100% ────────────────────────────── - -describe('issue #4 (CJS): cmdPhaseComplete — progress percent clamp', () => { - let tmpDir; - - afterEach(() => { - cleanup(tmpDir); - }); - - test('T2: Progress percent never exceeds 100 after double invocation', () => { - tmpDir = createFixture(); - - // Pre-load STATE.md with Completed Phases: 1, Total Phases: 1 (already 100%) - // so that a blind +1 on a second call would yield 200% - let stateContent = readStateMd(tmpDir); - stateContent = stateContent.replace('**Completed Phases:** 0', '**Completed Phases:** 1'); - stateContent = stateContent.replace('**Total Phases:** 2', '**Total Phases:** 1'); - stateContent = stateContent.replace('**Progress:** 0%', '**Progress:** 100%'); - fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateContent); - - // Also update ROADMAP to show just 1 phase total - const roadmap = [ - '# Roadmap', - '', - '- [ ] Phase 01: Foundation', - '', - '### Phase 01: Foundation', - '**Goal:** Build the foundation', - '**Plans:** 1 plans', - '', - '## Progress', - '', - '| Phase | Plans Complete | Status | Completed |', - '|-------|----------------|--------|-----------|', - '| 01. Foundation | 0/1 | Not started | - |', - '', - ].join('\n'); - fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap); - - // First call - capturePhaseComplete(tmpDir, '1'); - // Second call — this is the problematic one - capturePhaseComplete(tmpDir, '1'); - - const stateAfterBoth = readStateMd(tmpDir); - - // Check body Progress field - const progressStr = extractField(stateAfterBoth, 'Progress'); - const fmPercent = extractFrontmatterField(stateAfterBoth, 'progress.percent'); - - // Try to extract numeric percent from either source - const bodyPercentMatch = progressStr && progressStr.match(/(\d+)%/); - const bodyPercent = bodyPercentMatch ? parseInt(bodyPercentMatch[1], 10) : null; - const fmPercentNum = fmPercent ? parseInt(fmPercent, 10) : null; - - // At least one of body or frontmatter percent must exist and be ≤ 100 - const anyPercent = bodyPercent ?? fmPercentNum; - assert.ok( - anyPercent !== null, - `T2: Could not find any percent value in STATE.md\n\nSTATE:\n${stateAfterBoth}`, - ); - assert.ok( - anyPercent <= 100, - `T2 FAILED: Progress percent exceeds 100.\n` + - `Body Progress: "${progressStr}" (${bodyPercent}%), FM percent: ${fmPercentNum}%\n` + - `This is the #4 unclamped-percent bug — (N+1)/total can exceed 100.\n\n` + - `STATE:\n${stateAfterBoth}`, - ); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Regressions: issue #1159 — Defect A -// VERIFICATION.md with `previous_status: gaps_found` in the body but -// `status: passed` in frontmatter must NOT emit a "has unresolved gaps" warning. -// The bug: /status: gaps_found/.test(fullContent) matches the substring inside -// `previous_status: gaps_found`, causing a false positive. -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Build a minimal project fixture with a VERIFICATION.md file whose - * frontmatter status is `verFmStatus` and whose body contains `previous_status: gaps_found`. - * Phase 01 has a plan+summary; Phase 02 exists for next-phase detection. - */ -function createVerificationFixture(verFmStatus) { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1159-verif-')); - const planDir = path.join(tmpDir, '.planning'); - const phasesDir = path.join(planDir, 'phases'); - const phase01Dir = path.join(phasesDir, '01-foundation'); - fs.mkdirSync(phase01Dir, { recursive: true }); - fs.mkdirSync(path.join(phasesDir, '02-api'), { recursive: true }); - - fs.writeFileSync(path.join(planDir, 'ROADMAP.md'), [ - '# Roadmap', - '', - '- [ ] Phase 01: Foundation', - '- [ ] Phase 02: API', - '', - '### Phase 01: Foundation', - '**Goal:** Build the foundation', - '**Plans:** 1 plans', - '', - '## Progress', - '', - '| Phase | Plans Complete | Status | Completed |', - '|-------|----------------|--------|-----------|', - '| 01. Foundation | 0/1 | Not started | - |', - '| 02. API | 0/1 | Not started | - |', - '', - ].join('\n')); - - fs.writeFileSync(path.join(planDir, 'STATE.md'), [ - '# State', - '', - '**Current Phase:** 01', - '**Current Phase Name:** Foundation', - '**Status:** In progress', - '**Completed Phases:** 0', - '**Total Phases:** 2', - '**Progress:** 0%', - '', - ].join('\n')); - - // No REQUIREMENTS.md intentionally (not needed for this defect check) - - fs.writeFileSync(path.join(phase01Dir, '01-01-PLAN.md'), '# Plan 1\nDo the work.\n'); - fs.writeFileSync(path.join(phase01Dir, '01-01-SUMMARY.md'), '# Summary 1\nDone.\n'); - - // The VERIFICATION.md has the CURRENT status in frontmatter but historical - // `previous_status: gaps_found` in the body — this is the false-positive trigger. - fs.writeFileSync(path.join(phase01Dir, '01-VERIFICATION.md'), [ - '---', - `status: ${verFmStatus}`, - 'phase: "01"', - '---', - '', - '# Verification', - '', - '', - 'previous_status: gaps_found', - '', - '## Summary', - 'All checks passed on re-run.', - '', - ].join('\n')); - - return tmpDir; -} - -describe('issue #1159 (Defect A): VERIFICATION.md historical metadata must not trigger gap warning', () => { - let tmpDir; - - afterEach(() => { - cleanup(tmpDir); - }); - - test( - '#1159-A-1: status:passed + previous_status:gaps_found in body → NO "has unresolved gaps" warning', - () => { - tmpDir = createVerificationFixture('passed'); - const { output } = runGsdTools(['phase', 'complete', '1'], tmpDir); - // The output is JSON; parse and check warnings array - const parsed = JSON.parse(output); - const warnings = parsed.warnings || []; - const gapWarnings = warnings.filter((w) => /unresolved gaps/i.test(w)); - assert.equal( - gapWarnings.length, - 0, - `#1159-A-1 FAILED: got false gap warning(s) when frontmatter status=passed.\n` + - `Warnings: ${JSON.stringify(warnings)}\n` + - `(The regex /status: gaps_found/ matched 'previous_status: gaps_found' in the body.)`, - ); - }, - ); - - test( - '#1159-A-2 (boundary): status:gaps_found in frontmatter → blocks phase completion', - () => { - tmpDir = createVerificationFixture('gaps_found'); - const result = runGsdTools(['--json-errors', 'phase', 'complete', '1'], tmpDir); - assert.equal(result.success, false, 'gaps_found verification must block phase completion'); - const parsed = JSON.parse(result.error); - assert.equal(parsed.reason, 'phase_verification_incomplete'); - assert.match(parsed.message, /Gaps found/i); - }, - ); - - test( - '#1159-A-3 (boundary): status:human_needed in frontmatter → blocks phase completion', - () => { - tmpDir = createVerificationFixture('human_needed'); - const result = runGsdTools(['--json-errors', 'phase', 'complete', '1'], tmpDir); - assert.equal(result.success, false, 'human_needed verification must block phase completion'); - const parsed = JSON.parse(result.error); - assert.equal(parsed.reason, 'phase_verification_incomplete'); - assert.match(parsed.message, /Human verification required/i); - }, - ); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Regressions: issue #1159 — Defect B -// Requirement IDs (e.g. FILE-001) that appear under explicitly deferred/future/v2 -// sections in REQUIREMENTS.md must NOT be flagged as "missing from Traceability". -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Build a minimal project fixture with a REQUIREMENTS.md that has: - * - An active requirement ACTIVE-001 that IS in the Traceability table - * - A deferred requirement DEFER-001 under a "Deferred v2 Requirements" heading - * that is NOT in the Traceability table (correctly out of scope) - * - Optionally a truly-missing active requirement MISSING-001 (not in table) - */ -function createDeferredReqFixture({ includeMissingActive = false } = {}) { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1159-deferred-')); - const planDir = path.join(tmpDir, '.planning'); - const phasesDir = path.join(planDir, 'phases'); - const phase01Dir = path.join(phasesDir, '01-foundation'); - fs.mkdirSync(phase01Dir, { recursive: true }); - fs.mkdirSync(path.join(phasesDir, '02-api'), { recursive: true }); - - const missingActiveLines = includeMissingActive - ? ['', '- **MISSING-001** Active req not in traceability table.'] - : []; - - fs.writeFileSync(path.join(planDir, 'REQUIREMENTS.md'), [ - '# Requirements', - '', - '## Functional Requirements', - '', - '- **ACTIVE-001** Core feature must work.', - ...missingActiveLines, - '', - '## Deferred v2 Requirements', - '', - '- **DEFER-001** Nice-to-have for v2, explicitly out of scope.', - '', - '## Future Backlog', - '', - '- **FUTURE-001** Consider for next major release.', - '', - '## Traceability', - '', - '| Requirement | Phase | Status |', - '|-------------|-------|--------|', - '| ACTIVE-001 | Phase 01 | Pending |', - '', - ].join('\n')); - - // Roadmap references ACTIVE-001 for phase 01 - fs.writeFileSync(path.join(planDir, 'ROADMAP.md'), [ - '# Roadmap', - '', - '- [ ] Phase 01: Foundation', - '- [ ] Phase 02: API', - '', - '### Phase 01: Foundation', - '**Goal:** Build the foundation', - '**Requirements:** ACTIVE-001', - '**Plans:** 1 plans', - '', - '## Progress', - '', - '| Phase | Plans Complete | Status | Completed |', - '|-------|----------------|--------|-----------|', - '| 01. Foundation | 0/1 | Not started | - |', - '| 02. API | 0/1 | Not started | - |', - '', - ].join('\n')); - - fs.writeFileSync(path.join(planDir, 'STATE.md'), [ - '# State', - '', - '**Current Phase:** 01', - '**Current Phase Name:** Foundation', - '**Status:** In progress', - '**Completed Phases:** 0', - '**Total Phases:** 2', - '**Progress:** 0%', - '', - ].join('\n')); - - fs.writeFileSync(path.join(phase01Dir, '01-01-PLAN.md'), '# Plan 1\nDo the work.\n'); - fs.writeFileSync(path.join(phase01Dir, '01-01-SUMMARY.md'), '# Summary 1\nDone.\n'); - writePassedVerificationFile(phase01Dir); - - return tmpDir; -} - -describe('issue #1159 (Defect B): deferred/future requirement IDs must not trigger traceability warning', () => { - let tmpDir; - - afterEach(() => { - cleanup(tmpDir); - }); - - test( - '#1159-B-1: IDs under "Deferred v2 Requirements" and "Future Backlog" sections → NO traceability warning', - () => { - tmpDir = createDeferredReqFixture({ includeMissingActive: false }); - const { output } = runGsdTools(['phase', 'complete', '1'], tmpDir); - const parsed = JSON.parse(output); - const warnings = parsed.warnings || []; - const traceWarnings = warnings.filter((w) => /Traceability/i.test(w)); - assert.equal( - traceWarnings.length, - 0, - `#1159-B-1 FAILED: got false traceability warning(s) for deferred/future IDs.\n` + - `Warnings: ${JSON.stringify(warnings)}\n` + - `(DEFER-001 and FUTURE-001 are under deferred/future sections and must be ignored.)`, - ); - }, - ); - - test( - '#1159-B-2 (boundary): truly-missing ACTIVE ID (not in table, not in deferred section) → DOES warn', - () => { - tmpDir = createDeferredReqFixture({ includeMissingActive: true }); - const { output } = runGsdTools(['phase', 'complete', '1'], tmpDir); - const parsed = JSON.parse(output); - const warnings = parsed.warnings || []; - const traceWarnings = warnings.filter((w) => /Traceability/i.test(w)); - assert.ok( - traceWarnings.length > 0, - `#1159-B-2 FAILED: expected traceability warning for MISSING-001 (active, not in table) but got none.\n` + - `Warnings: ${JSON.stringify(warnings)}`, - ); - // Verify MISSING-001 is specifically mentioned - const mentionsMissing = traceWarnings.some((w) => w.includes('MISSING-001')); - assert.ok( - mentionsMissing, - `#1159-B-2 FAILED: warning exists but MISSING-001 not mentioned.\n` + - `Traceability warnings: ${JSON.stringify(traceWarnings)}`, - ); - }, - ); - - test( - '#1159-B-3 (boundary): deferred IDs must not contaminate warning even when active ID is also missing', - () => { - tmpDir = createDeferredReqFixture({ includeMissingActive: true }); - const { output } = runGsdTools(['phase', 'complete', '1'], tmpDir); - const parsed = JSON.parse(output); - const warnings = parsed.warnings || []; - const traceWarnings = warnings.filter((w) => /Traceability/i.test(w)); - // DEFER-001 and FUTURE-001 must NOT appear in the traceability warnings - const mentionsDefer = traceWarnings.some((w) => w.includes('DEFER-001') || w.includes('FUTURE-001')); - assert.ok( - !mentionsDefer, - `#1159-B-3 FAILED: deferred IDs (DEFER-001/FUTURE-001) appeared in traceability warning.\n` + - `Traceability warnings: ${JSON.stringify(traceWarnings)}`, - ); - }, - ); - - test( - '#1159-B-4 (subheading): IDs under sub-headings of a deferred section are also suppressed', - () => { - // Codex adversarial finding: splitting on EVERY heading failed to propagate - // deferred status to sub-headings (e.g. "## Future Backlog" → "### Sub"). - // The fix uses heading-depth tracking so sub-headings inherit deferred state. - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1159-subhead-')); - const planDir = path.join(tmpDir, '.planning'); - const phasesDir = path.join(planDir, 'phases'); - const phase01Dir = path.join(phasesDir, '01-foundation'); - fs.mkdirSync(phase01Dir, { recursive: true }); - fs.mkdirSync(path.join(phasesDir, '02-api'), { recursive: true }); - - fs.writeFileSync(path.join(planDir, 'REQUIREMENTS.md'), [ - '# Requirements', - '', - '## Functional Requirements', - '', - '- **ACTIVE-001** Core feature.', - '', - '## Future Backlog', - '', - '### Sub-category A', - '', - '- **SUB-001** This is under a sub-heading of a deferred section.', - '', - '## Traceability', - '', - '| Requirement | Phase | Status |', - '|-------------|-------|--------|', - '| ACTIVE-001 | Phase 01 | Pending |', - '', - ].join('\n')); - - fs.writeFileSync(path.join(planDir, 'ROADMAP.md'), [ - '# Roadmap', - '', - '- [ ] Phase 01: Foundation', - '- [ ] Phase 02: API', - '', - '### Phase 01: Foundation', - '**Goal:** Build the foundation', - '**Requirements:** ACTIVE-001', - '**Plans:** 1 plans', - '', - '## Progress', - '', - '| Phase | Plans Complete | Status | Completed |', - '|-------|----------------|--------|-----------|', - '| 01. Foundation | 0/1 | Not started | - |', - '| 02. API | 0/1 | Not started | - |', - '', - ].join('\n')); - - fs.writeFileSync(path.join(planDir, 'STATE.md'), [ - '# State', - '', - '**Current Phase:** 01', - '**Completed Phases:** 0', - '**Total Phases:** 2', - '**Progress:** 0%', - '', - ].join('\n')); - - fs.writeFileSync(path.join(phase01Dir, '01-01-PLAN.md'), '# Plan 1\n'); - fs.writeFileSync(path.join(phase01Dir, '01-01-SUMMARY.md'), '# Summary 1\n'); - writePassedVerificationFile(phase01Dir); - - const { output } = runGsdTools(['phase', 'complete', '1'], tmpDir); - const parsed = JSON.parse(output); - const warnings = parsed.warnings || []; - const traceWarnings = warnings.filter((w) => /Traceability/i.test(w)); - const mentionsSub = traceWarnings.some((w) => w.includes('SUB-001')); - assert.ok( - !mentionsSub, - `#1159-B-4 FAILED: SUB-001 (under sub-heading of deferred section) appeared in warning.\n` + - `Traceability warnings: ${JSON.stringify(traceWarnings)}`, - ); - }, - ); -}); diff --git a/tests/artifacts.test.cjs b/tests/artifacts.test.cjs index 47cc9c10a..1fcae98e2 100644 --- a/tests/artifacts.test.cjs +++ b/tests/artifacts.test.cjs @@ -78,3 +78,153 @@ describe('isCanonicalPlanningFile', () => { assert.strictEqual(isCanonicalPlanningFile('state.md'), false); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-2448-artifact-registry.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-2448-artifact-registry (consolidation epic #1969 B3 #1972)", () => { +'use strict'; + +// allow-test-rule: source-text-is-the-product (see #2448) +// Reads .md/.json/.yml product files whose deployed text IS what the +// runtime loads — testing text content tests the deployed contract. + +/** + * Tests for canonical artifact registry and gsd-health W019 lint (#2448). + */ + +const { test, describe, after } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const helpers = require('./helpers.cjs'); + +const { isCanonicalPlanningFile, CANONICAL_EXACT } = require('../gsd-core/bin/lib/artifacts.cjs'); +const { cmdValidateHealth } = require('../gsd-core/bin/lib/verify.cjs'); + +const _dirsToClean = []; +after(() => { for (const d of _dirsToClean) helpers.cleanup(d); }); + +function makeTempProject(files = {}) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2448-')); + _dirsToClean.push(dir); + fs.mkdirSync(path.join(dir, '.planning', 'phases'), { recursive: true }); + for (const [rel, content] of Object.entries(files)) { + const abs = path.join(dir, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, content, 'utf-8'); + } + return dir; +} + +const BASE_FILES = { + '.planning/PROJECT.md': '# P\n\n## What This Is\n\nX\n\n## Core Value\n\nY\n\n## Requirements\n\nZ\n', + '.planning/ROADMAP.md': '# Roadmap\n', + '.planning/STATE.md': '# State\n', + '.planning/config.json': '{}', +}; + +describe('artifacts.cjs — isCanonicalPlanningFile', () => { + test('returns true for all exact canonical names', () => { + for (const name of CANONICAL_EXACT) { + assert.ok(isCanonicalPlanningFile(name), `Expected ${name} to be canonical`); + } + }); + + test('returns true for version-stamped milestone audit file', () => { + assert.ok(isCanonicalPlanningFile('v1.0-MILESTONE-AUDIT.md')); + assert.ok(isCanonicalPlanningFile('v2.3.1-MILESTONE-AUDIT.md')); + }); + + test('returns true for RETROSPECTIVE.md (produced by /gsd-complete-milestone)', () => { + assert.strictEqual(isCanonicalPlanningFile('RETROSPECTIVE.md'), true); + }); + + test('returns false for clearly non-canonical names', () => { + assert.strictEqual(isCanonicalPlanningFile('MY-NOTES.md'), false); + assert.strictEqual(isCanonicalPlanningFile('scratch.md'), false); + assert.strictEqual(isCanonicalPlanningFile('random-output.md'), false); + }); + + test('returns false for phase-level artifacts at the root (they belong in phases/)', () => { + assert.strictEqual(isCanonicalPlanningFile('01-CONTEXT.md'), false); + assert.strictEqual(isCanonicalPlanningFile('01-01-PLAN.md'), false); + }); +}); + +describe('gsd-health W019 — unrecognized .planning/ root files', () => { + test('W019 fires for a non-canonical .md file at .planning/ root', () => { + const dir = makeTempProject({ + ...BASE_FILES, + '.planning/MY-NOTES.md': '# notes\n', + }); + + const result = cmdValidateHealth(dir, { repair: false }, false); + + const w019 = result.warnings.find(w => w.code === 'W019'); + assert.ok(w019, 'W019 should be emitted for unrecognized file'); + assert.ok(w019.message.includes('MY-NOTES.md'), 'warning should name the file'); + assert.strictEqual(w019.repairable, false, 'W019 is not auto-repairable'); + }); + + test('no W019 for canonical files', () => { + const dir = makeTempProject({ ...BASE_FILES }); + + const result = cmdValidateHealth(dir, { repair: false }, false); + + const w019 = result.warnings.find(w => w.code === 'W019'); + assert.strictEqual(w019, undefined, 'no W019 for canonical files'); + }); + + test('no W019 for phase subdirectory files (only root is checked)', () => { + const dir = makeTempProject({ + ...BASE_FILES, + '.planning/phases/01-foundation/01-01-PLAN.md': '---\nphase: "1"\n---\n', + }); + + const result = cmdValidateHealth(dir, { repair: false }, false); + + const w019 = result.warnings.find(w => w.code === 'W019'); + assert.strictEqual(w019, undefined, 'phase subdir files not flagged by W019'); + }); + + test('no W019 for version-stamped files like vX.Y-MILESTONE-AUDIT.md', () => { + const dir = makeTempProject({ + ...BASE_FILES, + '.planning/v1.0-MILESTONE-AUDIT.md': '# Audit\n', + }); + + const result = cmdValidateHealth(dir, { repair: false }, false); + + const w019 = result.warnings.find(w => w.code === 'W019'); + assert.strictEqual(w019, undefined, 'version-stamped audit file is canonical'); + }); + + test('multiple unrecognized files produce multiple W019 warnings', () => { + const dir = makeTempProject({ + ...BASE_FILES, + '.planning/scratch.md': '# scratch\n', + '.planning/temp-notes.md': '# temp\n', + }); + + const result = cmdValidateHealth(dir, { repair: false }, false); + + const w019s = result.warnings.filter(w => w.code === 'W019'); + assert.strictEqual(w019s.length, 2, 'one W019 per unrecognized file'); + }); + + test('templates/README.md exists and documents W019', () => { + const readme = fs.readFileSync( + path.join(__dirname, '../gsd-core/templates/README.md'), 'utf-8' + ); + assert.ok(readme.includes('W019'), 'README.md documents W019'); + assert.ok(readme.includes('artifacts.cjs'), 'README.md references artifacts.cjs for adding new artifacts'); + assert.ok(readme.includes('PROJECT.md'), 'README.md lists PROJECT.md as canonical'); + }); +}); + }); +} diff --git a/tests/audit-command-cutover.test.cjs b/tests/audit-command-cutover.test.cjs index 64fc338c5..ff130d0c9 100644 --- a/tests/audit-command-cutover.test.cjs +++ b/tests/audit-command-cutover.test.cjs @@ -592,3 +592,504 @@ describe('audit-open — output shape (#2911)', () => { }); }); } + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2836-audit-open-summary-uat-drift.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2836-audit-open-summary-uat-drift (consolidation epic #1969 B3 #1972)", () => { +/** + * Regression tests for bug #2836 + * + * audit-open had two convention drifts vs the documented workflows: + * 1. quick-task scanner looked for bare `SUMMARY.md`, but workflows/quick.md + * mandates `${quick_id}-SUMMARY.md`. Result: every documented quick task + * reported as `status: missing`. + * 2. UAT terminal-status enum only accepted `complete`, but + * workflows/execute-phase.md uses `resolved` post-gap-closure. + * Result: gap-closed UATs reported as open. + * + * Tests structurally invoke auditOpenArtifacts() against real fixtures on disk + * and assert the returned items array — never regex on raw file content. + */ + +'use strict'; + +const { describe, test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); + +const auditModule = require('../gsd-core/bin/lib/audit.cjs'); +const { auditOpenArtifacts } = auditModule; +const { cleanup } = require('./helpers.cjs'); + +function mkTmp() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'bug-2836-')); +} + +describe('bug #2836: audit-open quick-task summary filename + UAT terminal status', () => { + // Ensure GSD env vars do not redirect planningDir() away from our fixture. + let prevProject, prevWorkstream; + before(() => { + prevProject = process.env.GSD_PROJECT; + prevWorkstream = process.env.GSD_WORKSTREAM; + delete process.env.GSD_PROJECT; + delete process.env.GSD_WORKSTREAM; + }); + after(() => { + if (prevProject !== undefined) process.env.GSD_PROJECT = prevProject; + if (prevWorkstream !== undefined) process.env.GSD_WORKSTREAM = prevWorkstream; + }); + + test('quick task with ${quick_id}-SUMMARY.md is recognized as complete (not missing)', () => { + const cwd = mkTmp(); + try { + const quickId = '260429-test-foo'; + const taskDir = path.join(cwd, '.planning', 'quick', quickId); + fs.mkdirSync(taskDir, { recursive: true }); + fs.writeFileSync( + path.join(taskDir, `${quickId}-SUMMARY.md`), + '---\nstatus: complete\n---\ntest summary\n', + 'utf-8' + ); + + const result = auditOpenArtifacts(cwd); + const realQuickTasks = result.items.quick_tasks.filter( + i => !i.scan_error && !i._remainder_count + ); + + assert.equal( + realQuickTasks.length, 0, + `quick task with ${quickId}-SUMMARY.md (status: complete) must not appear ` + + `as an open item; got: ${JSON.stringify(realQuickTasks)}` + ); + assert.equal(result.counts.quick_tasks, 0); + } finally { + cleanup(cwd); + } + }); + + test('UAT with status: resolved is treated as terminal (not an open gap)', () => { + const cwd = mkTmp(); + try { + const phaseDir = path.join(cwd, '.planning', 'phases', '01-test'); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync( + path.join(phaseDir, '01-UAT.md'), + '---\nstatus: resolved\n---\nUAT body — gap closed via execute-phase flow.\n', + 'utf-8' + ); + + const result = auditOpenArtifacts(cwd); + const realUatGaps = result.items.uat_gaps.filter(i => !i.scan_error); + + assert.equal( + realUatGaps.length, 0, + `UAT with status: resolved must not appear as an open gap; ` + + `got: ${JSON.stringify(realUatGaps)}` + ); + assert.equal(result.counts.uat_gaps, 0); + } finally { + cleanup(cwd); + } + }); + + test('UAT with status: complete remains terminal (no regression)', () => { + const cwd = mkTmp(); + try { + const phaseDir = path.join(cwd, '.planning', 'phases', '02-test'); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync( + path.join(phaseDir, '02-UAT.md'), + '---\nstatus: complete\n---\nUAT body.\n', + 'utf-8' + ); + + const result = auditOpenArtifacts(cwd); + const realUatGaps = result.items.uat_gaps.filter(i => !i.scan_error); + assert.equal(realUatGaps.length, 0); + } finally { + cleanup(cwd); + } + }); + + test('UAT with status: pending is still flagged as an open gap', () => { + const cwd = mkTmp(); + try { + const phaseDir = path.join(cwd, '.planning', 'phases', '03-test'); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync( + path.join(phaseDir, '03-UAT.md'), + '---\nstatus: pending\n---\nresult: pending\n', + 'utf-8' + ); + + const result = auditOpenArtifacts(cwd); + const realUatGaps = result.items.uat_gaps.filter(i => !i.scan_error); + assert.equal(realUatGaps.length, 1, 'pending UAT must still be flagged'); + assert.equal(realUatGaps[0].status, 'pending'); + } finally { + cleanup(cwd); + } + }); + + test('quick task without any SUMMARY file is still flagged as missing', () => { + const cwd = mkTmp(); + try { + const quickId = '260429-test-bar'; + const taskDir = path.join(cwd, '.planning', 'quick', quickId); + fs.mkdirSync(taskDir, { recursive: true }); + // No SUMMARY file at all. + + const result = auditOpenArtifacts(cwd); + const realQuickTasks = result.items.quick_tasks.filter( + i => !i.scan_error && !i._remainder_count + ); + assert.equal(realQuickTasks.length, 1); + assert.equal(realQuickTasks[0].status, 'missing'); + } finally { + cleanup(cwd); + } + }); +}); + +describe('bug #2836: workflows/help.md one-liner reconciliation', () => { + test('help.md quick-task one-liner uses ${quick_id}-SUMMARY.md pattern', () => { + // After #3039, help content moved into help/modes/full.md. + const helpPath = path.resolve( + __dirname, '..', 'gsd-core', 'workflows', 'help', 'modes', 'full.md' + ); + const content = fs.readFileSync(helpPath, 'utf-8'); + + // Locate the documented "Result: Creates ..." quick-task one-liner and + // assert it references the per-task SUMMARY filename pattern, not bare + // SUMMARY.md. We parse by line to avoid false positives elsewhere. + const resultLines = content.split(/\r?\n/).filter(l => + l.includes('Result: Creates') && l.includes('.planning/quick/') + ); + assert.ok(resultLines.length > 0, 'expected a quick-task Result line in help.md'); + for (const line of resultLines) { + assert.ok( + /\$\{quick_id\}-SUMMARY\.md|NNN-slug-SUMMARY\.md/.test(line), + `help.md quick-task Result line must reference per-task SUMMARY filename ` + + `(e.g. \${quick_id}-SUMMARY.md); got: ${line}` + ); + } + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-950-quick-summary-status-complete.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-950-quick-summary-status-complete (consolidation epic #1969 B3 #1972)", () => { +// allow-test-rule: source-text-is-the-product (see #950) +/** + * Regression tests for bug #950 + * + * audit-open chronically flagged genuinely-complete quick tasks as [unknown] + * because NO shipped summary template carried a `status:` frontmatter field — + * so status was only emitted when the writing agent improvised it. + * + * The fix: add `status: complete` to all four summary templates and enforce it + * in the executor agent + quick.md workflow. Tests here exercise the scanner + * directly via auditOpenArtifacts() and also guard template text as a secondary + * contract check. + * + * Primary guard: behavioral audit-scanner tests (tasks read by scanQuickTasks) + * Secondary guard: template-contract text checks (template text IS the runtime contract) + * Writer-path guard: contract assertions on quick.md + gsd-executor.md (source-text-is-the-product) + */ + +'use strict'; + +const { describe, test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); + +const auditModule = require('../gsd-core/bin/lib/audit.cjs'); +const { auditOpenArtifacts } = auditModule; +const { cleanup } = require('./helpers.cjs'); + +const TEMPLATES_DIR = path.resolve(__dirname, '..', 'gsd-core', 'templates'); +const QUICK_MD = path.resolve(__dirname, '..', 'gsd-core', 'workflows', 'quick.md'); +const EXECUTOR_MD = path.resolve(__dirname, '..', 'agents', 'gsd-executor.md'); + +function mkTmp() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'bug-950-')); +} + +/** + * Extract the first YAML frontmatter block from a file's content. + * + * Two layouts are handled: + * - Leading frontmatter: file starts with `---\n…\n---` (summary-minimal/standard/complex.md) + * - Fenced frontmatter: frontmatter lives inside a ```markdown … ``` fence (summary.md, + * whose content IS a markdown example showing the template). In that case we extract + * the `---\n…\n---` block that sits immediately after the opening fence line. + * + * Returns the raw text of the YAML block (between the two `---` delimiters, exclusive), + * or null if no frontmatter could be found. + */ +function extractFrontmatter(content) { + // Case 1: file begins with --- (leading frontmatter) + if (/^---\r?\n/.test(content)) { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/); + return match ? match[1] : null; + } + + // Case 2: frontmatter is embedded inside a fenced block (```markdown\n---\n…\n---\n) + const fenceMatch = content.match(/```(?:markdown|md)?\r?\n(---\r?\n[\s\S]*?\r?\n---)\r?\n/); + if (fenceMatch) { + // Strip the outer --- delimiters to get just the YAML body + const block = fenceMatch[1]; + const inner = block.match(/^---\r?\n([\s\S]*?)\r?\n---$/); + return inner ? inner[1] : null; + } + + return null; +} + +describe('bug #950: quick-task SUMMARY must carry status: complete', () => { + // Ensure GSD env vars do not redirect planningDir() away from our fixture. + let prevProject, prevWorkstream; + before(() => { + prevProject = process.env.GSD_PROJECT; + prevWorkstream = process.env.GSD_WORKSTREAM; + delete process.env.GSD_PROJECT; + delete process.env.GSD_WORKSTREAM; + }); + after(() => { + if (prevProject !== undefined) process.env.GSD_PROJECT = prevProject; + if (prevWorkstream !== undefined) process.env.GSD_WORKSTREAM = prevWorkstream; + }); + + // ── Behavioral: scanner recognizes complete quick tasks ─────────────────── + + test('[PRIMARY] quick task SUMMARY with status: complete is NOT flagged open', () => { + // Simulates an executor that correctly wrote the SUMMARY with status: complete + // (as required after the fix). The scanner must report 0 open quick tasks. + const cwd = mkTmp(); + try { + const quickId = '260609-test-status-complete'; + const taskDir = path.join(cwd, '.planning', 'quick', quickId); + fs.mkdirSync(taskDir, { recursive: true }); + fs.writeFileSync( + path.join(taskDir, `${quickId}-SUMMARY.md`), + [ + '---', + 'status: complete', + 'date: 2026-06-09', + 'slug: test-status-complete', + '---', + '', + '# Quick Task Summary', + '', + 'Task completed successfully.', + ].join('\n'), + 'utf-8' + ); + + const result = auditOpenArtifacts(cwd); + const realQuickTasks = result.items.quick_tasks.filter( + i => !i.scan_error && !i._remainder_count + ); + + assert.equal( + realQuickTasks.length, + 0, + `quick task SUMMARY with status: complete must NOT appear as open; ` + + `got: ${JSON.stringify(realQuickTasks)}` + ); + assert.equal(result.counts.quick_tasks, 0); + } finally { + cleanup(cwd); + } + }); + + test('[PRIMARY] quick task SUMMARY without status: field is still flagged [unknown]', () => { + // Negative case: a SUMMARY that lacks status: still surfaces as [unknown]. + // This proves the scanner still catches real gaps — the fix must be on the writer side. + const cwd = mkTmp(); + try { + const quickId = '260609-test-no-status'; + const taskDir = path.join(cwd, '.planning', 'quick', quickId); + fs.mkdirSync(taskDir, { recursive: true }); + fs.writeFileSync( + path.join(taskDir, `${quickId}-SUMMARY.md`), + [ + '---', + 'date: 2026-06-09', + 'slug: test-no-status', + '---', + '', + '# Quick Task Summary', + '', + 'Task done, but no status field.', + ].join('\n'), + 'utf-8' + ); + + const result = auditOpenArtifacts(cwd); + const realQuickTasks = result.items.quick_tasks.filter( + i => !i.scan_error && !i._remainder_count + ); + + assert.equal( + realQuickTasks.length, + 1, + `quick task SUMMARY without status: must appear as open (unknown); ` + + `got: ${JSON.stringify(realQuickTasks)}` + ); + assert.equal(realQuickTasks[0].status, 'unknown', 'expected status to be unknown'); + } finally { + cleanup(cwd); + } + }); + + test('[PRIMARY] quick task without any SUMMARY is still flagged [missing]', () => { + // Proves the missing-SUMMARY case still surfaces. + const cwd = mkTmp(); + try { + const quickId = '260609-test-missing-summary'; + const taskDir = path.join(cwd, '.planning', 'quick', quickId); + fs.mkdirSync(taskDir, { recursive: true }); + // No SUMMARY file at all. + + const result = auditOpenArtifacts(cwd); + const realQuickTasks = result.items.quick_tasks.filter( + i => !i.scan_error && !i._remainder_count + ); + + assert.equal(realQuickTasks.length, 1, 'missing SUMMARY must still be flagged'); + assert.equal(realQuickTasks[0].status, 'missing'); + } finally { + cleanup(cwd); + } + }); + + test('[PRIMARY] SUMMARY with status: COMPLETE (uppercase) is also recognized', () => { + // Scanner lowercases before comparing — verify case-insensitivity holds. + const cwd = mkTmp(); + try { + const quickId = '260609-test-uppercase-complete'; + const taskDir = path.join(cwd, '.planning', 'quick', quickId); + fs.mkdirSync(taskDir, { recursive: true }); + fs.writeFileSync( + path.join(taskDir, `${quickId}-SUMMARY.md`), + '---\nstatus: COMPLETE\n---\n# Summary\nDone.\n', + 'utf-8' + ); + + const result = auditOpenArtifacts(cwd); + const realQuickTasks = result.items.quick_tasks.filter( + i => !i.scan_error && !i._remainder_count + ); + + assert.equal( + realQuickTasks.length, + 0, + `quick task SUMMARY with status: COMPLETE (uppercase) must not appear as open; ` + + `got: ${JSON.stringify(realQuickTasks)}` + ); + } finally { + cleanup(cwd); + } + }); + + // ── Secondary: template-contract checks ────────────────────────────────── + // Assertions are scoped to the actual YAML frontmatter block, not the whole file, + // so a stray `status: complete` in prose or examples cannot produce a false green. + + test('[TEMPLATE CONTRACT] summary.md contains status: complete in frontmatter', () => { + // summary.md is a documentation template — its frontmatter lives inside a + // ```markdown fence. extractFrontmatter() finds and returns that block. + const content = fs.readFileSync(path.join(TEMPLATES_DIR, 'summary.md'), 'utf-8'); + const fm = extractFrontmatter(content); + assert.ok( + fm !== null, + 'gsd-core/templates/summary.md: could not locate a YAML frontmatter block (leading --- or fenced ```markdown --- block)' + ); + assert.ok( + /^status:\s*complete\s*$/m.test(fm), + `gsd-core/templates/summary.md: \`status: complete\` not found in the frontmatter block.\n` + + `Frontmatter extracted:\n${fm}` + ); + }); + + test('[TEMPLATE CONTRACT] summary-minimal.md contains status: complete in frontmatter', () => { + const content = fs.readFileSync(path.join(TEMPLATES_DIR, 'summary-minimal.md'), 'utf-8'); + const fm = extractFrontmatter(content); + assert.ok( + fm !== null, + 'gsd-core/templates/summary-minimal.md: could not locate a leading YAML frontmatter block' + ); + assert.ok( + /^status:\s*complete\s*$/m.test(fm), + `gsd-core/templates/summary-minimal.md: \`status: complete\` not found in the frontmatter block.\n` + + `Frontmatter extracted:\n${fm}` + ); + }); + + test('[TEMPLATE CONTRACT] summary-standard.md contains status: complete in frontmatter', () => { + const content = fs.readFileSync(path.join(TEMPLATES_DIR, 'summary-standard.md'), 'utf-8'); + const fm = extractFrontmatter(content); + assert.ok( + fm !== null, + 'gsd-core/templates/summary-standard.md: could not locate a leading YAML frontmatter block' + ); + assert.ok( + /^status:\s*complete\s*$/m.test(fm), + `gsd-core/templates/summary-standard.md: \`status: complete\` not found in the frontmatter block.\n` + + `Frontmatter extracted:\n${fm}` + ); + }); + + test('[TEMPLATE CONTRACT] summary-complex.md contains status: complete in frontmatter', () => { + const content = fs.readFileSync(path.join(TEMPLATES_DIR, 'summary-complex.md'), 'utf-8'); + const fm = extractFrontmatter(content); + assert.ok( + fm !== null, + 'gsd-core/templates/summary-complex.md: could not locate a leading YAML frontmatter block' + ); + assert.ok( + /^status:\s*complete\s*$/m.test(fm), + `gsd-core/templates/summary-complex.md: \`status: complete\` not found in the frontmatter block.\n` + + `Frontmatter extracted:\n${fm}` + ); + }); + + // ── Writer-path contract checks ─────────────────────────────────────────── + // Guards quick.md and gsd-executor.md so a future edit removing `status: complete` + // from the SUMMARY-creation instructions would fail the suite before the bug recurs. + // (source-text-is-the-product: the deployed .md text IS the runtime contract for agents) + + test('[WRITER-PATH] quick.md constraints require status: complete in SUMMARY frontmatter', () => { + const content = fs.readFileSync(QUICK_MD, 'utf-8'); + assert.ok( + /status:\s*complete/.test(content), + 'gsd-core/workflows/quick.md must instruct the executor to write `status: complete` in the SUMMARY frontmatter. ' + + 'The block must contain the `status: complete` requirement so a future edit cannot silently drop it.' + ); + }); + + test('[WRITER-PATH] gsd-executor.md frontmatter spec requires status: complete', () => { + const content = fs.readFileSync(EXECUTOR_MD, 'utf-8'); + assert.ok( + /status[\s\S]{0,40}complete/.test(content), + 'agents/gsd-executor.md must document `status: complete` as a required SUMMARY frontmatter field. ' + + 'The Frontmatter section must include `status: complete` so the executor always emits it.' + ); + }); +}); + }); +} diff --git a/tests/bug-10-semver-policy-consolidation.test.cjs b/tests/bug-10-semver-policy-consolidation.test.cjs deleted file mode 100644 index 53b4703d8..000000000 --- a/tests/bug-10-semver-policy-consolidation.test.cjs +++ /dev/null @@ -1,30 +0,0 @@ -'use strict'; - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); - -const { - compareSemverCore, - isStableTripletSemver, -} = require('../gsd-core/bin/lib/semver-compare.cjs'); -const { isInstalledAheadOfLatest } = require('../hooks/gsd-statusline.js'); - -describe('bug #10: semver policy consolidation', () => { - test('shared comparator treats prerelease patch increment as newer than previous stable', () => { - assert.ok(compareSemverCore('1.2.1-beta.1', '1.2.0') > 0); - }); - - test('shared comparator ignores prerelease suffix for equal base versions', () => { - assert.equal(compareSemverCore('1.2.0-rc.1', '1.2.0'), 0); - }); - - test('stable-triplet validator excludes prerelease tags', () => { - assert.equal(isStableTripletSemver('1.2.0-rc.1'), false); - assert.equal(isStableTripletSemver('1.2.0'), true); - }); - - test('statusline dev-install detection uses shared comparator semantics', () => { - assert.equal(isInstalledAheadOfLatest('1.2.1-beta.1', '1.2.0'), true); - assert.equal(isInstalledAheadOfLatest('1.2.0-rc.1', '1.2.0'), false); - }); -}); diff --git a/tests/bug-14-progress-auto-flag-dropped.test.cjs b/tests/bug-14-progress-auto-flag-dropped.test.cjs deleted file mode 100644 index 82d2d276b..000000000 --- a/tests/bug-14-progress-auto-flag-dropped.test.cjs +++ /dev/null @@ -1,73 +0,0 @@ -// allow-test-rule: source-text-is-the-product -// The command markdown is loaded directly by runtime prompt assembly. -// This test verifies that --auto is documented in progress.md and handled in next.md. - -'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, '..'); - -describe('#14: /gsd:progress --next --auto flag must be documented and propagated', () => { - test('progress.md section documents --auto flag', () => { - const command = fs.readFileSync( - path.join(ROOT, 'commands', 'gsd', 'progress.md'), - 'utf8' - ); - - assert.ok( - command.includes('--auto'), - 'progress.md must document the --auto flag in the section' - ); - }); - - test('progress.md block explicitly passes --auto through to next workflow', () => { - const command = fs.readFileSync( - path.join(ROOT, 'commands', 'gsd', 'progress.md'), - 'utf8' - ); - - // Extract only the … block so this assertion is - // scoped to the handoff wiring, not just any occurrence in the file. - const processMatch = command.match(/([\s\S]*?)<\/process>/); - assert.ok( - processMatch, - 'progress.md must contain a block' - ); - const processBlock = processMatch[1]; - - assert.ok( - processBlock.includes('--auto'), - 'progress.md block must explicitly mention --auto so it is not silently stripped at the --next handoff' - ); - }); - - test('next.md show_and_execute step handles --auto to chain steps', () => { - const workflow = fs.readFileSync( - path.join(ROOT, 'gsd-core', 'workflows', 'next.md'), - 'utf8' - ); - - assert.ok( - workflow.includes('--auto'), - 'next.md must handle the --auto flag to chain step invocations automatically' - ); - }); - - test('next.md --auto chaining re-invokes /gsd:progress --next after step completion', () => { - const workflow = fs.readFileSync( - path.join(ROOT, 'gsd-core', 'workflows', 'next.md'), - 'utf8' - ); - - // The workflow must contain instructions to re-invoke /gsd:progress --next --auto - // after the determined step completes, enabling the chain. - assert.ok( - workflow.includes('--next --auto'), - 'next.md must instruct re-invocation of /gsd:progress --next --auto after step completion to enable chaining' - ); - }); -}); diff --git a/tests/bug-1695-state-patch-clobbers-phase-name.test.cjs b/tests/bug-1695-state-patch-clobbers-phase-name.test.cjs deleted file mode 100644 index 30ec14b4c..000000000 --- a/tests/bug-1695-state-patch-clobbers-phase-name.test.cjs +++ /dev/null @@ -1,113 +0,0 @@ -'use strict'; -// Regression test for issue #1695 — `state patch` of an unrelated field clobbers -// the curated `current_phase_name` frontmatter scalar. -// -// Root cause: readModifyWriteStateMd({resync:false}) still runs syncStateFrontmatter, -// which re-derives EVERY body-derived scalar from body prose. The #1264 restore -// covers `progress` only and #1230 covers `status`/`stopped_at`; `current_phase_name` -// was left exposed, and parseProsePhaseField's paren-over-dash preference made the -// re-derived value wrong (harvesting a parenthetical aside as the phase name). -// -// ADR-1769 Phase 6 fix: extend the #1230 delta heuristic to current_phase_name -// (gated by the field-classification table's preserve-always row). When the -// transform did NOT change the body Current Phase / Phase source line, the curated -// frontmatter value wins. - -const { describe, test, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); -const { extractFrontmatter } = require('../gsd-core/bin/lib/frontmatter.cjs'); - -function buildStateWithCuratedPhaseName({ phaseName = 'Native Global Hotkey', aside = 'next; Phase 15 landed, UAT deferred' } = {}) { - return [ - '---', - 'gsd_state_version: 1.0', - 'milestone: v1.0', - 'milestone_name: Test', - 'current_phase: "16"', - `current_phase_name: "${phaseName}"`, - 'status: executing', - 'progress:', - ' total_phases: 20', - ' completed_phases: 15', - ' total_plans: 40', - ' completed_plans: 30', - ' percent: 75', - '---', - '', - '# GSD State', - '', - '## Configuration', - 'Current Phase: 16', - 'Total Plans in Phase: 4', - 'Current Plan: 2', - 'Status: Executing Phase 16', - 'Last Activity: 2026-06-20', - '', - '## Current Position', - '', - `Phase: 16 — ${phaseName} (${aside})`, - 'Plan: 2 of 4', - 'Status: Executing Phase 16', - 'Last activity: 2026-06-20 — mid-flight', - '', - ].join('\n'); -} - -function readFm(statePath) { - return extractFrontmatter(fs.readFileSync(statePath, 'utf-8')); -} - -describe('#1695: state patch of an unrelated field preserves curated current_phase_name', () => { - let tmpDir; - beforeEach(() => { tmpDir = createTempProject(); }); - afterEach(() => { cleanup(tmpDir); }); - - test('patching Status does NOT clobber the curated current_phase_name', () => { - const statePath = path.join(tmpDir, '.planning', 'STATE.md'); - fs.writeFileSync(statePath, buildStateWithCuratedPhaseName()); - - const result = runGsdTools(['query', 'state.patch', JSON.stringify({ Status: 'Paused for review' })], tmpDir); - assert.ok(result.success, `state patch failed: ${result.error}`); - - const fm = readFm(statePath); - assert.strictEqual( - fm.current_phase_name, - 'Native Global Hotkey', - `current_phase_name must be preserved on an unrelated patch; got ${JSON.stringify(fm.current_phase_name)} (the paren-over-dash re-derivation clobbered it — #1695)`, - ); - }); - - test('patching Current Plan does NOT clobber the curated current_phase_name', () => { - const statePath = path.join(tmpDir, '.planning', 'STATE.md'); - fs.writeFileSync(statePath, buildStateWithCuratedPhaseName()); - - const result = runGsdTools(['query', 'state.patch', JSON.stringify({ 'Current Plan': '3' })], tmpDir); - assert.ok(result.success, `state patch failed: ${result.error}`); - - const fm = readFm(statePath); - assert.strictEqual(fm.current_phase_name, 'Native Global Hotkey'); - }); - - test('explicitly patching the body Phase name-source line still advances (delta does not over-pin)', () => { - const statePath = path.join(tmpDir, '.planning', 'STATE.md'); - fs.writeFileSync(statePath, buildStateWithCuratedPhaseName()); - - // Patching the body 'Phase' field (the parseProsePhaseField source for - // current_phase_name) changes the source line, so the #1230 delta must NOT - // fire — syncStateFrontmatter re-derives current_phase_name from the new line. - // (Acceptance criterion from #1743: the guard must not pin a scalar whose body - // source genuinely changed.) - const result = runGsdTools(['query', 'state.patch', JSON.stringify({ Phase: '17 — Brand New Phase Name' })], tmpDir); - assert.ok(result.success, `state patch failed: ${result.error}`); - - const fm = readFm(statePath); - // current_phase_name should be re-derived from the new body 'Phase' line - // (not pinned to the old curated value). - assert.notStrictEqual(fm.current_phase_name, 'Native Global Hotkey', - `current_phase_name must advance when the body Phase source changed; got ${JSON.stringify(fm.current_phase_name)}`); - }); -}); diff --git a/tests/bug-1829-inherit-model-profile.test.cjs b/tests/bug-1829-inherit-model-profile.test.cjs deleted file mode 100644 index ea96f7b1a..000000000 --- a/tests/bug-1829-inherit-model-profile.test.cjs +++ /dev/null @@ -1,218 +0,0 @@ -/** - * Regression tests for bug #1829 - * - * model_profile: "inherit" in .planning/config.json was not recognised as a - * valid profile. resolveModelInternal() silently fell back to "balanced", - * causing all agents to use "sonnet" instead of inheriting the parent model. - * - * Root cause in core.cjs: - * const profile = config.model_profile || 'balanced'; - * const agentModels = MODEL_PROFILES[agentType]; - * if (!agentModels) return 'sonnet'; - * const resolved = agentModels[profile] || agentModels['balanced'] || 'sonnet'; - * // agentModels['inherit'] is undefined → falls through to agentModels['balanced'] - * - * Fix 1 (core.cjs): add early return — if (profile === 'inherit') return 'inherit'; - * Fix 2 (verify.cjs): add 'inherit' to validProfiles so it doesn't trigger W004. - */ - -'use strict'; - -const { describe, test, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); -const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); - -const { resolveModelInternal } = require('../gsd-core/bin/lib/model-resolver.cjs'); - -// ─── Helpers ────────────────────────────────────────────────────────────────── - -function writeConfig(tmpDir, obj) { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'config.json'), - JSON.stringify(obj, null, 2) - ); -} - -function writeMinimalProjectMd(tmpDir) { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'PROJECT.md'), - '# Project\n\n## What This Is\n\nContent.\n\n## Core Value\n\nContent.\n\n## Requirements\n\nContent.\n' - ); -} - -function writeMinimalRoadmap(tmpDir) { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - '# Roadmap\n\n### Phase 1: First Phase\n' - ); -} - -function writeMinimalStateMd(tmpDir) { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'STATE.md'), - '# Session State\n\n## Current Position\n\nPhase: 1\n' - ); -} - -// ─── resolveModelInternal — inherit profile ─────────────────────────────────── - -describe('bug #1829: model_profile "inherit" — resolveModelInternal', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempProject(); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('returns "inherit" for gsd-planner when model_profile is "inherit"', () => { - writeConfig(tmpDir, { model_profile: 'inherit' }); - assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-planner'), 'inherit'); - }); - - test('returns "inherit" for gsd-executor when model_profile is "inherit"', () => { - writeConfig(tmpDir, { model_profile: 'inherit' }); - assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-executor'), 'inherit'); - }); - - test('returns "inherit" for gsd-phase-researcher when model_profile is "inherit"', () => { - writeConfig(tmpDir, { model_profile: 'inherit' }); - assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-phase-researcher'), 'inherit'); - }); - - test('returns "inherit" for gsd-codebase-mapper when model_profile is "inherit"', () => { - writeConfig(tmpDir, { model_profile: 'inherit' }); - assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-codebase-mapper'), 'inherit'); - }); - - test('returns "inherit" for gsd-verifier when model_profile is "inherit"', () => { - writeConfig(tmpDir, { model_profile: 'inherit' }); - assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-verifier'), 'inherit'); - }); - - test('returns "inherit" for unknown agent with inherit profile', () => { - writeConfig(tmpDir, { model_profile: 'inherit' }); - assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-nonexistent'), 'inherit'); - }); - - test('per-agent override takes precedence over inherit profile', () => { - writeConfig(tmpDir, { - model_profile: 'inherit', - model_overrides: { 'gsd-executor': 'haiku' }, - }); - // Override wins even when profile is inherit - assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-executor'), 'haiku'); - // Other agents without override still inherit - assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-planner'), 'inherit'); - }); - - test('does not silently fall back to "sonnet" (the original bug)', () => { - writeConfig(tmpDir, { model_profile: 'inherit' }); - // Before the fix, this returned 'sonnet' (via balanced fallback) - const model = resolveModelInternal(tmpDir, 'gsd-planner'); - assert.notStrictEqual(model, 'sonnet', 'inherit profile must not silently fall back to sonnet'); - }); -}); - -// ─── resolve-model CLI — inherit profile ────────────────────────────────────── - -describe('bug #1829: model_profile "inherit" — resolve-model CLI', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempProject(); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('CLI resolve-model returns "inherit" for gsd-executor with inherit profile', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'config.json'), - JSON.stringify({ model_profile: 'inherit' }, null, 2) - ); - - const result = runGsdTools('resolve-model gsd-executor', tmpDir); - assert.ok(result.success, `resolve-model failed: ${result.error}`); - - const parsed = JSON.parse(result.output); - assert.strictEqual(parsed.model, 'inherit'); - assert.strictEqual(parsed.profile, 'inherit'); - }); - - test('CLI resolve-model returns "inherit" for gsd-planner with inherit profile', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'config.json'), - JSON.stringify({ model_profile: 'inherit' }, null, 2) - ); - - const result = runGsdTools('resolve-model gsd-planner', tmpDir); - assert.ok(result.success, `resolve-model failed: ${result.error}`); - - const parsed = JSON.parse(result.output); - assert.strictEqual(parsed.model, 'inherit'); - }); -}); - -// ─── verify health — inherit profile is not a validation error ──────────────── - -describe('bug #1829: model_profile "inherit" — validate health does not warn W004', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempProject(); - writeMinimalProjectMd(tmpDir); - writeMinimalRoadmap(tmpDir); - writeMinimalStateMd(tmpDir); - fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-first-phase'), { recursive: true }); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('does not emit W004 for model_profile "inherit"', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'config.json'), - JSON.stringify({ - model_profile: 'inherit', - workflow: { - research: true, - plan_check: true, - verifier: true, - nyquist_validation: true, - }, - }, null, 2) - ); - - const result = runGsdTools('validate health', tmpDir); - assert.ok(result.success, `validate health failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.ok( - !output.warnings.some(w => w.code === 'W004'), - `inherit profile must not trigger W004: ${JSON.stringify(output.warnings)}` - ); - }); - - test('still emits W004 for genuinely invalid model_profile values', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'config.json'), - JSON.stringify({ model_profile: 'invalid-profile' }, null, 2) - ); - - const result = runGsdTools('validate health', tmpDir); - assert.ok(result.success, `validate health failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.ok( - output.warnings.some(w => w.code === 'W004'), - `Invalid profile should trigger W004: ${JSON.stringify(output.warnings)}` - ); - }); -}); diff --git a/tests/bug-190-bridge-collapse.test.cjs b/tests/bug-190-bridge-collapse.test.cjs deleted file mode 100644 index f847feede..000000000 --- a/tests/bug-190-bridge-collapse.test.cjs +++ /dev/null @@ -1,45 +0,0 @@ -'use strict'; - -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, '..'); - -function read(rel) { - return fs.readFileSync(path.join(ROOT, rel), 'utf8'); -} - -test('bridge collapse removes cjs-sdk-bridge and runtime-bridge-sync seam', () => { - const bridgePath = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'cjs-sdk-bridge.cjs'); - const sdkDir = path.join(ROOT, 'sdk'); - - assert.equal(fs.existsSync(bridgePath), false, 'cjs-sdk-bridge.cjs must be removed'); - assert.equal(fs.existsSync(sdkDir), false, 'sdk directory must be removed'); - - const routers = [ - 'gsd-core/bin/lib/init-command-router.cjs', - 'gsd-core/bin/lib/roadmap-command-router.cjs', - 'gsd-core/bin/lib/state-command-router.cjs', - 'gsd-core/bin/lib/validate-command-router.cjs', - 'gsd-core/bin/lib/verify-command-router.cjs', - 'gsd-core/bin/lib/phases-command-router.cjs', - ]; - - for (const rel of routers) { - const src = read(rel); - assert.equal( - src.includes('cjs-sdk-bridge.cjs'), - false, - `${rel} must not import cjs-sdk-bridge.cjs`, - ); - } - - const rootPkg = JSON.parse(read('package.json')); - assert.equal( - Object.prototype.hasOwnProperty.call(rootPkg.dependencies || {}, 'synckit'), - false, - 'package.json must not include synckit', - ); -}); diff --git a/tests/bug-1906-hook-relative-paths.test.cjs b/tests/bug-1906-hook-relative-paths.test.cjs deleted file mode 100644 index 406d43b19..000000000 --- a/tests/bug-1906-hook-relative-paths.test.cjs +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Regression tests for bug #1906 - * - * Local installs must anchor hook command paths to $CLAUDE_PROJECT_DIR so - * hooks resolve correctly regardless of the shell's current working directory. - * - * The original bug: local install hook commands used bare relative paths like - * `node .claude/hooks/gsd-context-monitor.js`. Claude Code persists the bash - * tool's cwd between calls, so a single `cd subdir && …` early in a session - * permanently broke every hook for the rest of that session. - * - * The fix prefixes all local hook commands with "$CLAUDE_PROJECT_DIR"/ so - * path resolution is always anchored to the project root. - */ - -'use strict'; - -const { describe, test, before } = require('node:test'); -const assert = require('node:assert/strict'); -const path = require('path'); - -const projection = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'shell-command-projection.cjs')); -const { projectLocalHookPrefix, projectShellCommandText } = projection; - -describe('bug #1906: local hook commands use $CLAUDE_PROJECT_DIR', () => { - before(() => { - assert.equal(typeof projectLocalHookPrefix, 'function'); - assert.equal(typeof projectShellCommandText, 'function'); - }); - - test('non-Gemini runtimes get $CLAUDE_PROJECT_DIR anchored local prefix', () => { - const prefix = projectLocalHookPrefix({ runtime: 'claude', dirName: '.claude' }); - assert.equal(prefix, '"$CLAUDE_PROJECT_DIR"/.claude'); - }); - - test('local command projection for non-Gemini keeps $CLAUDE_PROJECT_DIR anchor', () => { - const prefix = projectLocalHookPrefix({ runtime: 'claude', dirName: '.claude' }); - const command = projectShellCommandText({ - runnerToken: '"/usr/local/bin/node"', - argTokens: [`${prefix}/hooks/gsd-context-monitor.js`], - runtime: 'claude', - platform: 'linux', - }); - assert.equal( - command, - '"/usr/local/bin/node" "$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-context-monitor.js', - ); - }); -}); diff --git a/tests/bug-1967-cache-invalidation.test.cjs b/tests/bug-1967-cache-invalidation.test.cjs deleted file mode 100644 index fbda4cda4..000000000 --- a/tests/bug-1967-cache-invalidation.test.cjs +++ /dev/null @@ -1,106 +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 tests for #1967 cache invalidation. - * - * The disk scan cache in buildStateFrontmatter must be invalidated on - * writeStateMd to prevent stale reads if multiple state-mutating - * operations occur within the same Node process. This matters for: - * - SDK callers that require() gsd-tools.cjs as a module - * - Future dispatcher extensions that handle compound operations - * - Tests that import state.cjs directly - */ - -'use strict'; - -const { test, describe, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const os = require('node:os'); - -const state = require('../gsd-core/bin/lib/state.cjs'); -const { cleanup } = require('./helpers.cjs'); - -describe('buildStateFrontmatter cache invalidation (#1967)', () => { - let tmpDir; - let planningDir; - let phasesDir; - let statePath; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1967-cache-')); - planningDir = path.join(tmpDir, '.planning'); - phasesDir = path.join(planningDir, 'phases'); - fs.mkdirSync(phasesDir, { recursive: true }); - - // Create a minimal config and STATE.md - fs.writeFileSync( - path.join(planningDir, 'config.json'), - JSON.stringify({ project_code: 'TEST' }) - ); - - statePath = path.join(planningDir, 'STATE.md'); - fs.writeFileSync(statePath, [ - '# State', - '', - '**Current Phase:** 1', - '**Status:** executing', - '**Total Phases:** 2', - '', - ].join('\n')); - - // Start with one phase directory containing one PLAN - const phase1 = path.join(phasesDir, '01-foo'); - fs.mkdirSync(phase1); - fs.writeFileSync(path.join(phase1, '01-1-PLAN.md'), '---\nphase: 1\nplan: 1\n---\n# Plan\n'); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('writeStateMd invalidates cache so subsequent reads see new disk state', () => { - // First write — populates cache via buildStateFrontmatter - const content1 = fs.readFileSync(statePath, 'utf-8'); - state.writeStateMd(statePath, content1, tmpDir); - - // Create a NEW phase directory AFTER the first write - // Without cache invalidation, the second write would still see only 1 phase - const phase2 = path.join(phasesDir, '02-bar'); - fs.mkdirSync(phase2); - fs.writeFileSync(path.join(phase2, '02-1-PLAN.md'), '---\nphase: 2\nplan: 1\n---\n# Plan\n'); - fs.writeFileSync(path.join(phase2, '02-1-SUMMARY.md'), '---\nstatus: complete\n---\n# Summary\n'); - - // Second write in the SAME process — must see the new phase - const content2 = fs.readFileSync(statePath, 'utf-8'); - state.writeStateMd(statePath, content2, tmpDir); - - // Read back and parse frontmatter to verify it reflects 2 phases, not 1 - const result = fs.readFileSync(statePath, 'utf-8'); - const fmMatch = result.match(/^---\r?\n([\s\S]*?)\r?\n---/); - assert.ok(fmMatch, 'STATE.md should have frontmatter after writeStateMd'); - - const fm = fmMatch[1]; - // Should show 2 total phases (the new disk state), not 1 (stale cache) - const totalPhasesMatch = fm.match(/total_phases:\s*(\d+)/); - assert.ok(totalPhasesMatch, 'frontmatter should contain total_phases'); - assert.strictEqual( - parseInt(totalPhasesMatch[1], 10), - 2, - 'total_phases should reflect new disk state (2), not stale cache (1)' - ); - - // Should show 1 completed phase (phase 2 has SUMMARY) - const completedMatch = fm.match(/completed_phases:\s*(\d+)/); - assert.ok(completedMatch, 'frontmatter should contain completed_phases'); - assert.strictEqual( - parseInt(completedMatch[1], 10), - 1, - 'completed_phases should reflect new disk state (1 complete), not stale cache (0)' - ); - }); -}); diff --git a/tests/bug-2424-reapply-patches-baseline-detection.test.cjs b/tests/bug-2424-reapply-patches-baseline-detection.test.cjs deleted file mode 100644 index f56a65e95..000000000 --- a/tests/bug-2424-reapply-patches-baseline-detection.test.cjs +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Bug #2424: reapply-patches pristine-baseline detection uses first-add commit - * - * The three-way merge baseline detection previously used `git log --diff-filter=A` - * which returns the commit that FIRST added the file. On repos that have been - * through multiple GSD update cycles, this returns a stale, many-versions-old - * baseline — not the version immediately prior to the current update. - * - * Fix: Option A must prefer `pristine_hashes` from backup-meta.json to locate - * the correct baseline commit by SHA-256 matching, with a fallback to the - * first-add heuristic only when no pristine hash is recorded. - * - * #2790: reapply-patches.md (which contained the inline Option A / Option B workflow) - * was consolidated into update.md as the --reapply flag. The behavioral contract - * (pristine_hashes preference, fallback to first-add) is maintained in the - * update.md workflow's --reapply path. These tests now verify the consolidation. - */ - -// allow-test-rule: source-text-is-the-product -// gsd-core/workflows/update.md is the installed runtime workflow — -// its text IS the deployed behavioral contract. - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -// #2790: reapply-patches.md (command with inline workflow) was deleted. -// The --reapply functionality is now in update.md. -const UPDATE_MD = path.join(__dirname, '..', 'commands', 'gsd', 'update.md'); - -/** - * Parse a field from YAML frontmatter between --- markers. - * Returns null if the frontmatter or field is absent. - */ -function parseFrontmatterField(content, field) { - const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); - if (!fmMatch) return null; - const fm = fmMatch[1]; - const quoted = fm.match(new RegExp(`^${field}:\\s+"((?:[^"\\\\]|\\\\.)*)"\\s*$`, 'm')); - if (quoted) return quoted[1]; - const plain = fm.match(new RegExp(`^${field}:\\s+(.+)$`, 'm')); - if (plain) return plain[1].trim(); - return null; -} - -describe('reapply-patches pristine baseline detection (#2424)', () => { - test('reapply-patches.md command is deleted (absorbed into update.md --reapply, #2790)', () => { - const oldPath = path.join(__dirname, '..', 'commands', 'gsd', 'reapply-patches.md'); - assert.ok(!fs.existsSync(oldPath), 'reapply-patches.md should be absent (absorbed into update.md --reapply)'); - }); - - test('update.md argument-hint declares --reapply as consolidated entry point', () => { - const content = fs.readFileSync(UPDATE_MD, 'utf-8'); - const argHint = parseFrontmatterField(content, 'argument-hint'); - assert.ok( - argHint && argHint.includes('--reapply'), - `update.md argument-hint must declare --reapply flag; got: ${argHint || '(none)'}` - ); - }); - - test('update.md workflow references backup-meta.json for pristine-hash baseline', () => { - // #2790: The behavioral contract (pristine_hashes from backup-meta.json as primary - // baseline source) is implemented in the update.md workflow (gsd-core/workflows/update.md), - // not the command file. The command delegates via --reapply flag. - // Verify the underlying workflow has this content. - const workflowPath = path.join(__dirname, '..', 'gsd-core', 'workflows', 'update.md'); - const workflowContent = fs.readFileSync(workflowPath, 'utf-8'); - assert.ok( - workflowContent.includes('backup-meta.json'), - 'gsd-core/workflows/update.md must reference backup-meta.json (pristine_hashes baseline source)' - ); - }); - - test('update.md exists as consolidated entry point', () => { - assert.ok(fs.existsSync(UPDATE_MD), 'update.md must exist as consolidated entry point'); - }); - - test('update.md argument-hint declares full consolidated flag surface (--sync | --reapply)', () => { - // Validates that both flags absorbed from the deleted micro-skills are declared - // in the command contract, not just --reapply alone. - const content = fs.readFileSync(UPDATE_MD, 'utf-8'); - const argHint = parseFrontmatterField(content, 'argument-hint'); - assert.ok( - argHint && argHint.includes('--sync') && argHint.includes('--reapply'), - `update.md argument-hint must declare both --sync and --reapply; got: ${argHint || '(none)'}` - ); - }); -}); diff --git a/tests/bug-2530-valid-config-keys.test.cjs b/tests/bug-2530-valid-config-keys.test.cjs deleted file mode 100644 index 6e2d053d7..000000000 --- a/tests/bug-2530-valid-config-keys.test.cjs +++ /dev/null @@ -1,163 +0,0 @@ -'use strict'; - -/** - * Regression tests for config key bugs: - * #2530 — workflow._auto_chain_active is internal state, must not be in VALID_CONFIG_KEYS - * #2531 — hooks.workflow_guard is used by hook and documented but missing from VALID_CONFIG_KEYS - * #2532 — workflow.ui_review is used in autonomous.md but missing from config validation - * #2533 — workflow.max_discuss_passes is used in discuss-phase.md but missing from VALID_CONFIG_KEYS - * #2535 — sub_repos and plan_checker legacy keys need CONFIG_KEY_SUGGESTIONS migration hints - * #3162 — resolve_model_ids missing from VALID_CONFIG_KEYS; workflow._auto_chain_active must be - * accepted by isValidConfigKey (written by workflows) without being user-visible - * #1747 — buildNewProjectConfig emits four search-provider keys (tavily_search, ref_search, - * perplexity, jina) that research-provider.cts consumes but were missing from - * VALID_CONFIG_KEYS, causing /gsd-settings unknown-key warnings on fresh projects - */ - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs'); - -const { - VALID_CONFIG_KEYS, - isCentralConfigKey, - isValidConfigKey, -} = require('../gsd-core/bin/lib/config-schema.cjs'); - -const capabilityRegistry = require('../gsd-core/bin/lib/capability-registry.cjs'); - -describe('VALID_CONFIG_KEYS correctness', () => { - test('#2530: workflow._auto_chain_active must not be in VALID_CONFIG_KEYS (internal state)', () => { - assert.ok( - !VALID_CONFIG_KEYS.has('workflow._auto_chain_active'), - 'workflow._auto_chain_active is internal runtime state and must not be user-settable' - ); - }); - - test('#2531: hooks.workflow_guard must be in VALID_CONFIG_KEYS (used by hook, documented)', () => { - assert.ok( - VALID_CONFIG_KEYS.has('hooks.workflow_guard'), - 'hooks.workflow_guard is read by gsd-workflow-guard.js hook and documented in CONFIGURATION.md' - ); - }); - - test('#2532: workflow.ui_review must remain valid but is no longer centrally owned', () => { - assert.strictEqual( - isValidConfigKey('workflow.ui_review'), - true, - 'workflow.ui_review is still user-facing config and must validate' - ); - assert.strictEqual( - isCentralConfigKey('workflow.ui_review'), - false, - 'workflow.ui_review is owned by the UI capability after ADR-857 Phase 6 cutover' - ); - }); - - test('#2533: workflow.max_discuss_passes must be in VALID_CONFIG_KEYS (used in discuss-phase.md)', () => { - assert.ok( - VALID_CONFIG_KEYS.has('workflow.max_discuss_passes'), - 'workflow.max_discuss_passes is read in discuss-phase.md via gsd-sdk query config-get' - ); - }); - - test('#3162: resolve_model_ids must be in VALID_CONFIG_KEYS (documented user-facing key)', () => { - assert.ok( - VALID_CONFIG_KEYS.has('resolve_model_ids'), - 'resolve_model_ids is documented in CONFIGURATION.md and read by core.cjs/session-runner.ts' - ); - }); - - test('#3162: workflow._auto_chain_active must be accepted by isValidConfigKey (written by workflows)', () => { - assert.strictEqual( - isValidConfigKey('workflow._auto_chain_active'), - true, - 'workflow._auto_chain_active is written by plan-phase, execute-phase, discuss-phase, transition workflows via config-set' - ); - }); -}); - -describe('#1747: new-project config emits only schema-recognized provider keys', () => { - // buildNewProjectConfig emits seven search-provider availability flags and - // research-provider.cts providerAvailability() consumes all seven, but only - // three were in VALID_CONFIG_KEYS → /gsd-settings warned on the four - // unregistered keys (tavily_search, ref_search, perplexity, jina) for every - // freshly generated .planning/config.json. - - // The four keys that were emitted + consumed but missing from the schema. - const MISSING_KEYS = ['tavily_search', 'ref_search', 'perplexity', 'jina']; - - // Every config-driven provider flag read by providerAvailability() in - // src/research-provider.cts. context7/websearch are excluded: hardcoded - // `true`, not config-gated, so no config key to register. - const PROVIDER_CONFIG_KEYS = [ - 'brave_search', - 'firecrawl', - 'exa_search', - 'tavily_search', - 'ref_search', - 'perplexity', - 'jina', - ]; - - test('the four previously-missing provider keys are in VALID_CONFIG_KEYS', () => { - const absent = MISSING_KEYS.filter((k) => !VALID_CONFIG_KEYS.has(k)); - assert.deepStrictEqual( - absent, - [], - `These provider keys are emitted by buildNewProjectConfig and consumed by research-provider.cts but missing from VALID_CONFIG_KEYS:\n ${absent.join('\n ')}\n\nAdd them to gsd-core/bin/shared/config-schema.manifest.json (validKeys).` - ); - }); - - test('every config-driven research-provider flag is registered in the schema (drift guard)', () => { - const drifted = PROVIDER_CONFIG_KEYS.filter((k) => !VALID_CONFIG_KEYS.has(k)); - assert.deepStrictEqual( - drifted, - [], - `These research-provider config flags are not in VALID_CONFIG_KEYS — a fresh /gsd-new-project config would trigger an unknown-key warning under /gsd-settings:\n ${drifted.join('\n ')}\n\nWhen you add a provider to providerAvailability() in src/research-provider.cts, also register its config key in gsd-core/bin/shared/config-schema.manifest.json.` - ); - }); -}); - -describe('ADR-857 Phase 6 capability config ownership', () => { - test('migrated capability config keys are valid through the registry, not central schema residue', () => { - const capabilityKeys = Object.keys(capabilityRegistry.configSchema || {}).sort(); - assert.ok(capabilityKeys.length > 0, 'expected generated registry config schema keys'); - - for (const key of capabilityKeys) { - assert.strictEqual(isValidConfigKey(key), true, `${key} must remain accepted by config validation`); - assert.strictEqual(isCentralConfigKey(key), false, `${key} must be capability-owned, not central`); - assert.strictEqual(VALID_CONFIG_KEYS.has(key), false, `${key} must not remain in central VALID_CONFIG_KEYS`); - } - }); -}); - -describe('CONFIG_KEY_SUGGESTIONS migration hints (#2535)', () => { - let tmpDir; - - test('config-set sub_repos emits "Did you mean planning.sub_repos?" suggestion', (t) => { - tmpDir = createTempProject(); - t.after(() => cleanup(tmpDir)); - - const result = runGsdTools(['config-set', 'sub_repos', '[]'], tmpDir); - assert.ok(!result.success, 'config-set sub_repos should fail'); - const combined = result.error + result.output; - assert.ok( - combined.includes('Did you mean') && combined.includes('planning.sub_repos'), - `Expected "Did you mean planning.sub_repos?" in error, got:\nstdout: ${result.output}\nstderr: ${result.error}` - ); - }); - - test('config-set plan_checker emits "Did you mean workflow.plan_check?" suggestion', (t) => { - tmpDir = createTempProject(); - t.after(() => cleanup(tmpDir)); - - const result = runGsdTools(['config-set', 'plan_checker', 'true'], tmpDir); - assert.ok(!result.success, 'config-set plan_checker should fail'); - const combined = result.error + result.output; - assert.ok( - combined.includes('Did you mean') && combined.includes('workflow.plan_check'), - `Expected "Did you mean workflow.plan_check?" in error, got:\nstdout: ${result.output}\nstderr: ${result.error}` - ); - }); -}); diff --git a/tests/bug-2554-decimal-phase-filter.test.cjs b/tests/bug-2554-decimal-phase-filter.test.cjs deleted file mode 100644 index 4e77453c6..000000000 --- a/tests/bug-2554-decimal-phase-filter.test.cjs +++ /dev/null @@ -1,80 +0,0 @@ -/** - * Regression test for bug #2554: - * state disk-scan excludes decimal phase dirs (e.g. "00.1") from progress counts. - * - * Root cause: getMilestonePhaseFilter normalized phase IDs with `replace(/^0+/, '')`, - * which over-strips on decimals: "00.1" → ".1", while the disk-side extractor - * applied to "00.1-" yields "0.1" — so the dir is excluded from the milestone. - * - * Fix: strip leading zeros only when followed by a digit (`replace(/^0+(?=\d)/, '')`), - * preserving the zero before the decimal point. - */ - -const { test, describe, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); -const { createTempProject, cleanup } = require('./helpers.cjs'); -const { getMilestonePhaseFilter } = require('../gsd-core/bin/lib/roadmap-parser.cjs'); - -describe('bug #2554 — getMilestonePhaseFilter decimal phase dirs', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempProject(); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('matches decimal phase directory like "00.1-" against ROADMAP phase "00.1"', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - [ - '## Roadmap v1.0: Current', - '', - '### Phase 0: Foundation', - '**Goal:** foundation', - '', - '### Phase 00.1: Inserted urgent work', - '**Goal:** inserted', - '', - '### Phase 1: Feature', - '**Goal:** feature', - ].join('\n') - ); - - const filter = getMilestonePhaseFilter(tmpDir); - - // Phase 00.1 inserted between Phase 0 and Phase 1 must match its on-disk dir. - assert.strictEqual( - filter('00.1-app-namespace-rename'), - true, - 'decimal phase dir "00.1-" must be counted in the milestone' - ); - - // Neighbours should still match (no regression). - assert.strictEqual(filter('0-foundation'), true); - assert.strictEqual(filter('1-feature'), true); - }); - - test('preserves existing behavior for zero-padded integer phases', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - [ - '## Roadmap v1.0: Current', - '', - '### Phase 01: One', - '**Goal:** g', - '', - '### Phase 10: Ten', - '**Goal:** g', - ].join('\n') - ); - - const filter = getMilestonePhaseFilter(tmpDir); - assert.strictEqual(filter('01-one'), true); - assert.strictEqual(filter('10-ten'), true); - }); -}); diff --git a/tests/bug-2557-gemini-local-hook-paths.test.cjs b/tests/bug-2557-gemini-local-hook-paths.test.cjs deleted file mode 100644 index 13ac2101a..000000000 --- a/tests/bug-2557-gemini-local-hook-paths.test.cjs +++ /dev/null @@ -1,52 +0,0 @@ -'use strict'; - -/** - * Bug #2557: Gemini CLI local hook commands must NOT use $CLAUDE_PROJECT_DIR. - * - * $CLAUDE_PROJECT_DIR is a Claude Code-specific env variable. Gemini CLI does - * not set it. On Windows, Gemini's own variable-substitution + path-join logic - * produced a doubled path like `D:\Projects\GSD\'D:\Projects\GSD'`, causing - * every local project hook to fail at SessionStart. - * - * Fix: localPrefix is now runtime-conditional. Gemini/Antigravity use bare - * dirName (relative path) since they always run project hooks with the project - * dir as cwd. Claude Code and others still use "$CLAUDE_PROJECT_DIR"/ (#1906). - */ - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const path = require('node:path'); - -const projection = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'shell-command-projection.cjs')); -const { projectLocalHookPrefix, projectShellCommandText } = projection; - -describe('bug #2557: Gemini/Antigravity local hooks use relative paths (not $CLAUDE_PROJECT_DIR)', () => { - test('Gemini local prefix is bare dirName', () => { - assert.equal(projectLocalHookPrefix({ runtime: 'gemini', dirName: '.gemini' }), '.gemini'); - }); - - test('Antigravity local prefix is bare dirName', () => { - assert.equal(projectLocalHookPrefix({ runtime: 'antigravity', dirName: '.agents' }), '.agents'); - }); - - test('non-Gemini local prefix remains $CLAUDE_PROJECT_DIR anchored', () => { - assert.equal( - projectLocalHookPrefix({ runtime: 'claude', dirName: '.claude' }), - '"$CLAUDE_PROJECT_DIR"/.claude', - ); - }); - - test('Gemini local command projection does not contain "$CLAUDE_PROJECT_DIR"', () => { - const prefix = projectLocalHookPrefix({ runtime: 'gemini', dirName: '.gemini' }); - const command = projectShellCommandText({ - runnerToken: '"/usr/local/bin/node"', - argTokens: [`${prefix}/hooks/gsd-check-update.js`], - runtime: 'gemini', - platform: 'linux', - }); - assert.ok( - !command.includes('$CLAUDE_PROJECT_DIR'), - 'Gemini local command must not include $CLAUDE_PROJECT_DIR', - ); - }); -}); diff --git a/tests/bug-2638-sub-repos-canonical-location.test.cjs b/tests/bug-2638-sub-repos-canonical-location.test.cjs deleted file mode 100644 index 3254b4622..000000000 --- a/tests/bug-2638-sub-repos-canonical-location.test.cjs +++ /dev/null @@ -1,143 +0,0 @@ -/** - * Regression test for bug #2638. - * - * loadConfig previously migrated/synced sub_repos to the TOP-LEVEL - * `parsed.sub_repos`, but the KNOWN_TOP_LEVEL allowlist only recognizes - * `planning.sub_repos` (per #2561 — canonical location). That asymmetry - * made loadConfig write a key it then warns is unknown on the next read. - * - * Fix: writers target `parsed.planning.sub_repos` and strip any stale - * top-level copy during the same migration pass. - */ - -const { test, describe, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); -const { execFileSync } = require('child_process'); -const { createTempProject, cleanup } = require('./helpers.cjs'); - -const { loadConfig } = require('../gsd-core/bin/lib/config-loader.cjs'); - -function makeSubRepo(parent, name) { - const dir = path.join(parent, name); - fs.mkdirSync(dir, { recursive: true }); - execFileSync('git', ['init'], { cwd: dir, stdio: 'pipe' }); -} - -function readConfig(tmpDir) { - return JSON.parse( - fs.readFileSync(path.join(tmpDir, '.planning', 'config.json'), 'utf-8') - ); -} - -function writeConfig(tmpDir, obj) { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'config.json'), - JSON.stringify(obj, null, 2) - ); -} - -describe('bug #2638 — sub_repos canonical location', () => { - let tmpDir; - let originalCwd; - let stderrCapture; - let origStderrWrite; - - beforeEach(() => { - tmpDir = createTempProject(); - originalCwd = process.cwd(); - stderrCapture = ''; - origStderrWrite = process.stderr.write; - process.stderr.write = (chunk) => { stderrCapture += chunk; return true; }; - }); - - afterEach(() => { - process.stderr.write = origStderrWrite; - process.chdir(originalCwd); - cleanup(tmpDir); - }); - - test('does not warn when planning.sub_repos is set (no top-level sub_repos)', () => { - makeSubRepo(tmpDir, 'backend'); - makeSubRepo(tmpDir, 'frontend'); - writeConfig(tmpDir, { - planning: { sub_repos: ['backend', 'frontend'] }, - }); - - loadConfig(tmpDir); - - assert.ok( - !stderrCapture.includes('unknown config key'), - `should not warn for planning.sub_repos, got: ${stderrCapture}` - ); - assert.ok( - !stderrCapture.includes('sub_repos'), - `should not mention sub_repos at all, got: ${stderrCapture}` - ); - }); - - test('migrates legacy multiRepo:true into planning.sub_repos (not top-level)', () => { - makeSubRepo(tmpDir, 'backend'); - makeSubRepo(tmpDir, 'frontend'); - writeConfig(tmpDir, { multiRepo: true }); - - loadConfig(tmpDir); - - const after = readConfig(tmpDir); - assert.deepStrictEqual( - after.planning?.sub_repos, - ['backend', 'frontend'], - 'migration should write to planning.sub_repos' - ); - assert.strictEqual( - Object.prototype.hasOwnProperty.call(after, 'sub_repos'), - false, - 'migration must not leave a top-level sub_repos key' - ); - assert.strictEqual(after.multiRepo, undefined, 'legacy multiRepo should be removed'); - - assert.ok( - !stderrCapture.includes('unknown config key'), - `post-migration read should not warn, got: ${stderrCapture}` - ); - }); - - test('filesystem sync writes detected list to planning.sub_repos only', () => { - makeSubRepo(tmpDir, 'api'); - makeSubRepo(tmpDir, 'web'); - writeConfig(tmpDir, { planning: { sub_repos: ['api'] } }); - - loadConfig(tmpDir); - - const after = readConfig(tmpDir); - assert.deepStrictEqual(after.planning?.sub_repos, ['api', 'web']); - assert.strictEqual( - Object.prototype.hasOwnProperty.call(after, 'sub_repos'), - false, - 'sync must not create a top-level sub_repos key' - ); - assert.ok( - !stderrCapture.includes('unknown config key'), - `sync should not produce unknown-key warning, got: ${stderrCapture}` - ); - }); - - test('stale top-level sub_repos is stripped on load', () => { - makeSubRepo(tmpDir, 'backend'); - writeConfig(tmpDir, { - sub_repos: ['backend'], - planning: { sub_repos: ['backend'] }, - }); - - loadConfig(tmpDir); - - const after = readConfig(tmpDir); - assert.strictEqual( - Object.prototype.hasOwnProperty.call(after, 'sub_repos'), - false, - 'stale top-level sub_repos should be removed to self-heal legacy installs' - ); - assert.deepStrictEqual(after.planning?.sub_repos, ['backend']); - }); -}); diff --git a/tests/bug-2660-one-liner-extraction.test.cjs b/tests/bug-2660-one-liner-extraction.test.cjs deleted file mode 100644 index 7fafd39eb..000000000 --- a/tests/bug-2660-one-liner-extraction.test.cjs +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Bug #2660: `gsd-tools milestone complete ` writes MILESTONES.md - * bullets that read "- One-liner:" (the literal label) instead of the prose - * after the label. - * - * Root cause: extractOneLinerFromBody() matches the first **...** span. In - * `**One-liner:** prose`, the first span contains only `One-liner:` so the - * function returns the label instead of the prose after it. - */ - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const path = require('path'); - -const { extractOneLinerFromBody } = require( - path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'core-utils.cjs') -); - -describe('bug #2660: extractOneLinerFromBody', () => { - test('a) body-style **One-liner:** label returns prose after the label', () => { - const content = - '# Phase 2 Plan 01: Foundation Summary\n\n**One-liner:** Real prose here.\n'; - assert.strictEqual(extractOneLinerFromBody(content), 'Real prose here.'); - }); - - test('b) frontmatter-only one-liner returns null (caller handles frontmatter)', () => { - const content = - '---\none-liner: Set up project\n---\n\n# Phase 1: Foundation Summary\n\nBody prose with no bold line.\n'; - assert.strictEqual(extractOneLinerFromBody(content), null); - }); - - test('c) no one-liner at all returns null', () => { - const content = - '# Phase 1: Foundation Summary\n\nJust some narrative, no bold line.\n'; - assert.strictEqual(extractOneLinerFromBody(content), null); - }); - - test('d) bold spans inside the prose are preserved', () => { - const content = - '# Phase 1: Foundation Summary\n\n**One-liner:** This is **important** stuff.\n'; - assert.strictEqual( - extractOneLinerFromBody(content), - 'This is **important** stuff.' - ); - }); - - test('e) empty prose after label returns null (no bogus bullet)', () => { - const empty = - '# Phase 1: Foundation Summary\n\n**One-liner:**\n\nRest of body.\n'; - const whitespace = - '# Phase 1: Foundation Summary\n\n**One-liner:** \n\nRest of body.\n'; - assert.strictEqual(extractOneLinerFromBody(empty), null); - assert.strictEqual(extractOneLinerFromBody(whitespace), null); - }); - - test('f) legacy bare **prose** format still works (no label, no colon)', () => { - // Preserve pre-existing behavior: SUMMARY files historically used - // `**bold prose**` with no label. See tests/commands.test.cjs:366 and - // tests/milestone.test.cjs:451 — both assert this form. - const content = - '---\nphase: "01"\n---\n\n# Phase 1: Foundation Summary\n\n**JWT auth with refresh rotation using jose library**\n\n## Performance\n'; - assert.strictEqual( - extractOneLinerFromBody(content), - 'JWT auth with refresh rotation using jose library' - ); - }); - - test('g) other **Label:** prefixes (e.g. Summary:) also capture prose after label', () => { - const content = - '# Phase 1: Foundation Summary\n\n**Summary:** Built the thing.\n'; - assert.strictEqual(extractOneLinerFromBody(content), 'Built the thing.'); - }); - - test('h) CRLF line endings (Windows) are handled', () => { - const content = - '---\r\nphase: "01"\r\n---\r\n\r\n# Phase 1: Foundation Summary\r\n\r\n**One-liner:** Windows-authored prose.\r\n'; - assert.strictEqual( - extractOneLinerFromBody(content), - 'Windows-authored prose.' - ); - }); -}); diff --git a/tests/bug-2770-annotate-deps-int-coerce.test.cjs b/tests/bug-2770-annotate-deps-int-coerce.test.cjs deleted file mode 100644 index 6c4b77397..000000000 --- a/tests/bug-2770-annotate-deps-int-coerce.test.cjs +++ /dev/null @@ -1,204 +0,0 @@ -'use strict'; - -// allow-test-rule: source-text-is-the-product -// Reads .md/.json/.yml product files whose deployed text IS what the -// runtime loads — testing text content tests the deployed contract. - -/** - * Regression — issue #2770 - * - * `roadmap.annotate-dependencies` crashes with - * `TypeError: t.trim is not a function` when must_haves.truths contains a - * non-string scalar (e.g., a YAML int like `- 3` interpreted by an upstream - * parser as a number, or a kv-shaped item whose value is numeric). - * - * The original guard `if (typeof t !== 'string') continue` skipped silently — - * which avoids the crash but **drops the constraint from cross-cutting - * analysis**. The required behaviour is to **coerce, not skip**: a numeric - * scalar `3` must be surfaced as the string "3", and a kv-shaped truth like - * `{ title: "X", count: 3 }` must contribute its title to the analysis. - * - * The two literal cases called out in the issue title (bare-int `depends_on` - * values) are also exercised here as regression guards on the frontmatter - * parser to prove the dependency is preserved as a string and never dropped. - */ - -const { test, describe, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); -const { extractFrontmatter } = require('../gsd-core/bin/lib/frontmatter.cjs'); - -function makePlanProject(files = {}) { - const dir = createTempProject(); - fs.writeFileSync(path.join(dir, '.planning', 'ROADMAP.md'), ''); - fs.mkdirSync(path.join(dir, '.planning', 'phases', '01-foundation'), { recursive: true }); - for (const [rel, content] of Object.entries(files)) { - const abs = path.join(dir, rel); - fs.mkdirSync(path.dirname(abs), { recursive: true }); - fs.writeFileSync(abs, content, 'utf-8'); - } - return dir; -} - -const ROADMAP = [ - '# Roadmap', - '', - '### Phase 1: Foundation', - '**Goal:** Set up project', - '**Plans:** 2 plans', - '', - 'Plans:', - '- [ ] 01-01-PLAN.md — Set up DB', - '- [ ] 01-02-PLAN.md — Build API', - '', -].join('\n'); - -// PLAN where must_haves.truths includes a bare numeric scalar AND a kv-shaped -// item whose value is numeric — both must be surfaced as cross-cutting -// constraints when shared across plans, not silently dropped. -const PLAN_NUMERIC_TRUTH = (wave, sharedTitle) => [ - '---', - 'phase: "1"', - `plan: "01-0${wave}"`, - 'type: standard', - `wave: ${wave}`, - 'depends_on: []', - 'files_modified: []', - 'autonomous: true', - 'must_haves:', - ' truths:', - ` - title: ${sharedTitle}`, - ' count: 3', - ' - 42', - ' artifacts: []', - ' key_links: []', - '---', - '', - `Plan ${wave}`, - '', -].join('\n'); - -describe('bug #2770 — non-string truths must be coerced, not dropped', () => { - let tmpDir; - afterEach(() => cleanup(tmpDir)); - - test('numeric scalar truth shared across 2+ plans is surfaced as cross-cutting constraint', () => { - // Both plans share the numeric truth `42`. Pre-fix: silently dropped by - // `typeof t !== 'string' continue`, so cross_cutting_constraints == 0. - // Post-fix: coerced to "42" and surfaced as a constraint. - const PLAN_BARE_INT_TRUTH = (wave) => [ - '---', - 'phase: "1"', - `plan: "01-0${wave}"`, - 'type: standard', - `wave: ${wave}`, - 'depends_on: []', - 'files_modified: []', - 'autonomous: true', - 'must_haves:', - ' truths:', - ' - 42', - ' artifacts: []', - ' key_links: []', - '---', - '', - `Plan ${wave}`, - '', - ].join('\n'); - tmpDir = makePlanProject({ - '.planning/ROADMAP.md': ROADMAP, - '.planning/phases/01-foundation/01-01-PLAN.md': PLAN_BARE_INT_TRUTH(1), - '.planning/phases/01-foundation/01-02-PLAN.md': PLAN_BARE_INT_TRUTH(2), - }); - - const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const out = JSON.parse(result.output); - assert.strictEqual( - out.cross_cutting_constraints, - 1, - 'numeric truth shared across plans must be surfaced (coerced), not dropped' - ); - - const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); - assert.ok(roadmap.includes('Cross-cutting constraints:'), - 'cross-cutting subsection present'); - assert.ok(/-\s*42\b/.test(roadmap), - 'numeric truth "42" surfaced as a string in the roadmap'); - }); - - test('kv-shaped truth with numeric value uses title and contributes to cross-cutting analysis', () => { - // Both plans share `{ title: 'shared-rule', count: 3 }`. Pre-fix: - // typeof === 'object' so silently skipped → constraint dropped. - // Post-fix: title extracted, surfaced in cross-cutting subsection. - tmpDir = makePlanProject({ - '.planning/ROADMAP.md': ROADMAP, - '.planning/phases/01-foundation/01-01-PLAN.md': PLAN_NUMERIC_TRUTH(1, 'shared-rule'), - '.planning/phases/01-foundation/01-02-PLAN.md': PLAN_NUMERIC_TRUTH(2, 'shared-rule'), - }); - - const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const out = JSON.parse(result.output); - // Both plans share two truths: the kv-shaped { title: 'shared-rule', ... } - // and the bare numeric 42. Pre-fix neither would survive the typeof guard; - // post-fix both are coerced and surfaced. - assert.strictEqual( - out.cross_cutting_constraints, - 2, - 'kv-shaped truth and numeric truth both surface, not dropped' - ); - - const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); - assert.ok(roadmap.includes('shared-rule'), - 'title from kv-shaped truth surfaced in cross-cutting list'); - assert.ok(/-\s*42\b/.test(roadmap), - 'numeric truth surfaced as a string'); - }); -}); - -describe('bug #2770 — bare-int depends_on values parse as preserved strings', () => { - test('scalar bare-int depends_on parses as string "3" (not dropped, not numeric)', () => { - // Per issue title: a YAML scalar `depends_on: 3` must be preserved as the - // string "3". The frontmatter parser already returns strings here; this - // test pins the behaviour so a future "convert YAML scalars to numbers" - // optimization cannot silently regress dependency tracking. - const fm = extractFrontmatter([ - '---', - 'phase: "1"', - 'plan: "01"', - 'depends_on: 3', - '---', - 'body', - '', - ].join('\n')); - assert.strictEqual(typeof fm.depends_on, 'string', - 'scalar depends_on must remain a string after parse'); - assert.strictEqual(fm.depends_on, '3', - 'bare int 3 must be preserved as the string "3"'); - }); - - test('inline-array bare-int depends_on parses to ["3","4"] (preserved as strings)', () => { - const fm = extractFrontmatter([ - '---', - 'phase: "1"', - 'plan: "01"', - 'depends_on: [3, 4]', - '---', - 'body', - '', - ].join('\n')); - assert.ok(Array.isArray(fm.depends_on), - 'inline array depends_on must be an array'); - assert.deepStrictEqual(fm.depends_on, ['3', '4'], - 'bare ints in inline array must be preserved as strings — never dropped'); - // Critical: assert *length* matches input. A naive `if (typeof !== string) continue` - // would silently drop entries; we must coerce, not skip. - assert.strictEqual(fm.depends_on.length, 2, - 'no dependency may be silently dropped during coercion'); - }); -}); diff --git a/tests/bug-2798-context-window-config-key.test.cjs b/tests/bug-2798-context-window-config-key.test.cjs deleted file mode 100644 index b17c1011a..000000000 --- a/tests/bug-2798-context-window-config-key.test.cjs +++ /dev/null @@ -1,101 +0,0 @@ -/** - * Regression test for bug #2798 - * - * `gsd-sdk query config-set context_window ` was rejected with - * "Unknown config key: context_window" because context_window was missing - * from VALID_CONFIG_KEYS in sdk/src/query/config-schema.ts. - * - * The fix added 'context_window' to the allowlist. - * This test prevents future drift where the key gets accidentally removed. - */ - -'use strict'; - -const { describe, test, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const { execFileSync } = require('node:child_process'); -const { createTempProject, cleanup } = require('./helpers.cjs'); - -const REPO_ROOT = path.join(__dirname, '..'); -const SDK_CLI = path.join(REPO_ROOT, 'sdk', 'dist', 'cli.js'); - -function runConfigSet(key, value, projectDir) { - const argv = ['query', 'config-set', key, String(value), '--project-dir', projectDir]; - let stdout = ''; - let exitCode = 0; - try { - stdout = execFileSync(process.execPath, [SDK_CLI, ...argv], { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - env: { ...process.env, GSD_SESSION_KEY: '' }, - }); - } catch (err) { - exitCode = err.status ?? 1; - stdout = err.stdout?.toString() ?? ''; - } - let json = null; - try { json = JSON.parse(stdout.trim()); } catch { /* ok */ } - return { exitCode, json }; -} - -describe('bug-2798: context_window is a valid config key', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempProject('gsd-test-2798-'); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'config.json'), - JSON.stringify({ mode: 'balanced' }) - ); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('config-set context_window succeeds (not rejected as unknown key)', (t) => { - if (!fs.existsSync(SDK_CLI)) { - t.skip('sdk/dist/cli.js not built — run `cd sdk && npm run build` to enable this integration test'); - return; - } - const result = runConfigSet('context_window', 1000000, tmpDir); - - assert.strictEqual(result.exitCode, 0, 'should exit 0 (key is valid)'); - assert.ok(result.json !== null, 'should emit JSON'); - assert.strictEqual(result.json?.updated, true, 'updated should be true'); - assert.strictEqual(result.json?.key, 'context_window'); - }); - - test('context_window value is written to config.json', (t) => { - if (!fs.existsSync(SDK_CLI)) { - t.skip('sdk/dist/cli.js not built — run `cd sdk && npm run build` to enable this integration test'); - return; - } - runConfigSet('context_window', 500000, tmpDir); - - const config = JSON.parse( - fs.readFileSync(path.join(tmpDir, '.planning', 'config.json'), 'utf-8') - ); - assert.strictEqual(config.context_window, 500000, 'context_window should be persisted'); - }); - - test('config-schema CJS and SDK allowlists both include context_window', (t) => { - if (!fs.existsSync(path.join(REPO_ROOT, 'sdk', 'dist', 'query', 'config-schema.js'))) { - t.skip('sdk/dist/query/config-schema.js not built — run `cd sdk && npm run build` to enable this integration test'); - return; - } - const cjsSchema = require(path.join(REPO_ROOT, 'gsd-core', 'bin', 'lib', 'config-schema.cjs')); - const sdkSchema = require(path.join(REPO_ROOT, 'sdk', 'dist', 'query', 'config-schema.js')); - - assert.ok( - cjsSchema.VALID_CONFIG_KEYS.has('context_window'), - 'CJS VALID_CONFIG_KEYS must include context_window' - ); - assert.ok( - sdkSchema.VALID_CONFIG_KEYS.has('context_window'), - 'SDK VALID_CONFIG_KEYS must include context_window' - ); - }); -}); diff --git a/tests/bug-2801-ingest-docs-handler.test.cjs b/tests/bug-2801-ingest-docs-handler.test.cjs deleted file mode 100644 index 7ed979676..000000000 --- a/tests/bug-2801-ingest-docs-handler.test.cjs +++ /dev/null @@ -1,150 +0,0 @@ -/** - * Regression test for bug #2801 - * - * `/gsd-ingest-docs` was broken because: - * 1. `workflows/ingest-docs.md` called `gsd-sdk query init.ingest-docs` but the - * installed binary is `gsd-tools` (not `gsd-sdk`). - * 2. `gsd-tools init` had no `ingest-docs` case in its dispatch switch. - * - * The fix: - * - Added `case 'ingest-docs'` to the `init` switch in `gsd-tools.cjs`. - * - Exported `cmdInitIngestDocs` from `init.cjs`. - * - Updated `workflows/ingest-docs.md` to call `gsd-tools init ingest-docs`. - * - * This test prevents regression of the dispatch omission. - */ - -'use strict'; - -const { describe, test, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const childProc = require('node:child_process'); -const { createTempProject, cleanup, TOOLS_PATH } = require('./helpers.cjs'); - -const REPO_ROOT = path.join(__dirname, '..'); -const WORKFLOW_FILE = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'ingest-docs.md'); - -function spawnGsdTools(args, projectDir) { - let stdout = ''; - let exitCode = 0; - try { - stdout = childProc.execFileSync( - process.execPath, - [TOOLS_PATH, ...args, '--cwd', projectDir], - { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - env: { ...process.env, GSD_SESSION_KEY: '' }, - } - ); - } catch (err) { - exitCode = err.status ?? 1; - stdout = (err.stdout?.toString() ?? '') + (err.stderr?.toString() ?? ''); - } - return { exitCode, stdout }; -} - -describe('bug-2801: gsd-tools init ingest-docs handler exists', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempProject('gsd-test-2801-'); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('init ingest-docs exits 0 (not "Unknown init workflow")', () => { - const { exitCode, stdout } = spawnGsdTools(['init', 'ingest-docs', '--raw'], tmpDir); - assert.strictEqual(exitCode, 0, `expected exit 0, got: ${stdout}`); - }); - - test('init ingest-docs returns JSON with project_exists field', () => { - const { exitCode, stdout } = spawnGsdTools(['init', 'ingest-docs', '--raw'], tmpDir); - assert.strictEqual(exitCode, 0); - let json; - try { json = JSON.parse(stdout.trim()); } catch { assert.fail(`non-JSON output: ${stdout}`); } - assert.ok(Object.prototype.hasOwnProperty.call(json, 'project_exists'), 'project_exists present'); - }); - - test('init ingest-docs returns JSON with planning_exists field', () => { - const { exitCode, stdout } = spawnGsdTools(['init', 'ingest-docs', '--raw'], tmpDir); - assert.strictEqual(exitCode, 0); - const json = JSON.parse(stdout.trim()); - assert.ok(Object.prototype.hasOwnProperty.call(json, 'planning_exists'), 'planning_exists present'); - }); - - test('init ingest-docs returns JSON with has_git field', () => { - const { exitCode, stdout } = spawnGsdTools(['init', 'ingest-docs', '--raw'], tmpDir); - assert.strictEqual(exitCode, 0); - const json = JSON.parse(stdout.trim()); - assert.ok(Object.prototype.hasOwnProperty.call(json, 'has_git'), 'has_git present'); - }); - - test('init ingest-docs returns JSON with project_path field', () => { - const { exitCode, stdout } = spawnGsdTools(['init', 'ingest-docs', '--raw'], tmpDir); - assert.strictEqual(exitCode, 0); - const json = JSON.parse(stdout.trim()); - assert.ok(Object.prototype.hasOwnProperty.call(json, 'project_path'), 'project_path present'); - assert.ok(Object.prototype.hasOwnProperty.call(json, 'commit_docs'), 'commit_docs present'); - }); - - test('planning_exists is true when .planning/ directory exists', () => { - const { exitCode, stdout } = spawnGsdTools(['init', 'ingest-docs', '--raw'], tmpDir); - assert.strictEqual(exitCode, 0); - const json = JSON.parse(stdout.trim()); - assert.strictEqual(json.planning_exists, true, 'planning_exists should be true (.planning/ created by createTempProject)'); - }); -}); - -describe('bug-2801: ingest-docs.md workflow calls gsd-tools not gsd-sdk', () => { - test('no bash code block in ingest-docs.md calls gsd-sdk', () => { - const content = fs.readFileSync(WORKFLOW_FILE, 'utf-8'); - // Extract bash fenced code blocks structurally. - const bashBlocks = []; - const codeBlockRe = /```bash\r?\n([\s\S]*?)```/g; - let m; - while ((m = codeBlockRe.exec(content)) !== null) { - bashBlocks.push(m[1]); - } - assert.ok(bashBlocks.length > 0, 'expected bash code blocks in workflow'); - - // Check every line in every bash block — not just lines that start with the token, - // since gsd-sdk can appear in subshell expansions like $(gsd-sdk query ...). - const sdkCalls = bashBlocks - .join('\n') - .split('\n') - .filter((line) => /\bgsd-sdk\b/.test(line)); - - assert.deepStrictEqual( - sdkCalls, - [], - `workflow bash blocks still reference gsd-sdk (should use gsd-tools): ${sdkCalls.join(', ')}` - ); - }); - - test('ingest-docs.md init step uses the gsd_run launcher (#637)', () => { - const content = fs.readFileSync(WORKFLOW_FILE, 'utf-8'); - // Parse fenced bash blocks structurally — do not match raw markdown text. - const codeBlockRe = /```bash\r?\n([\s\S]*?)```/g; - const bashLines = [...content.matchAll(codeBlockRe)] - .flatMap((m) => m[1].split('\n')) - .filter((l) => !/^\s*#/.test(l)); - // #637 routes ingest-docs through the resolved `gsd_run` launcher instead of - // the hardcoded `node "$HOME/.../gsd-tools.cjs"` path (which misses global - // installs). The legacy bare `gsd-tools` form remains the bug and is still - // rejected by bug-2851's repo-wide guard. - const initLine = bashLines.find((l) => - /\bgsd_run\s+init\s+ingest-docs\b/.test(l) - ); - assert.ok(initLine, 'workflow must invoke init ingest-docs via the gsd_run launcher (#637)'); - }); - - test('cmdInitIngestDocs is exported from init.cjs', () => { - const init = require(path.join(REPO_ROOT, 'gsd-core', 'bin', 'lib', 'init.cjs')); - assert.strictEqual(typeof init.cmdInitIngestDocs, 'function', 'cmdInitIngestDocs must be exported'); - }); -}); diff --git a/tests/bug-2836-audit-open-summary-uat-drift.test.cjs b/tests/bug-2836-audit-open-summary-uat-drift.test.cjs deleted file mode 100644 index 8d4487800..000000000 --- a/tests/bug-2836-audit-open-summary-uat-drift.test.cjs +++ /dev/null @@ -1,181 +0,0 @@ -/** - * Regression tests for bug #2836 - * - * audit-open had two convention drifts vs the documented workflows: - * 1. quick-task scanner looked for bare `SUMMARY.md`, but workflows/quick.md - * mandates `${quick_id}-SUMMARY.md`. Result: every documented quick task - * reported as `status: missing`. - * 2. UAT terminal-status enum only accepted `complete`, but - * workflows/execute-phase.md uses `resolved` post-gap-closure. - * Result: gap-closed UATs reported as open. - * - * Tests structurally invoke auditOpenArtifacts() against real fixtures on disk - * and assert the returned items array — never regex on raw file content. - */ - -'use strict'; - -const { describe, test, before, after } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const os = require('node:os'); - -const auditModule = require('../gsd-core/bin/lib/audit.cjs'); -const { auditOpenArtifacts } = auditModule; -const { cleanup } = require('./helpers.cjs'); - -function mkTmp() { - return fs.mkdtempSync(path.join(os.tmpdir(), 'bug-2836-')); -} - -describe('bug #2836: audit-open quick-task summary filename + UAT terminal status', () => { - // Ensure GSD env vars do not redirect planningDir() away from our fixture. - let prevProject, prevWorkstream; - before(() => { - prevProject = process.env.GSD_PROJECT; - prevWorkstream = process.env.GSD_WORKSTREAM; - delete process.env.GSD_PROJECT; - delete process.env.GSD_WORKSTREAM; - }); - after(() => { - if (prevProject !== undefined) process.env.GSD_PROJECT = prevProject; - if (prevWorkstream !== undefined) process.env.GSD_WORKSTREAM = prevWorkstream; - }); - - test('quick task with ${quick_id}-SUMMARY.md is recognized as complete (not missing)', () => { - const cwd = mkTmp(); - try { - const quickId = '260429-test-foo'; - const taskDir = path.join(cwd, '.planning', 'quick', quickId); - fs.mkdirSync(taskDir, { recursive: true }); - fs.writeFileSync( - path.join(taskDir, `${quickId}-SUMMARY.md`), - '---\nstatus: complete\n---\ntest summary\n', - 'utf-8' - ); - - const result = auditOpenArtifacts(cwd); - const realQuickTasks = result.items.quick_tasks.filter( - i => !i.scan_error && !i._remainder_count - ); - - assert.equal( - realQuickTasks.length, 0, - `quick task with ${quickId}-SUMMARY.md (status: complete) must not appear ` + - `as an open item; got: ${JSON.stringify(realQuickTasks)}` - ); - assert.equal(result.counts.quick_tasks, 0); - } finally { - cleanup(cwd); - } - }); - - test('UAT with status: resolved is treated as terminal (not an open gap)', () => { - const cwd = mkTmp(); - try { - const phaseDir = path.join(cwd, '.planning', 'phases', '01-test'); - fs.mkdirSync(phaseDir, { recursive: true }); - fs.writeFileSync( - path.join(phaseDir, '01-UAT.md'), - '---\nstatus: resolved\n---\nUAT body — gap closed via execute-phase flow.\n', - 'utf-8' - ); - - const result = auditOpenArtifacts(cwd); - const realUatGaps = result.items.uat_gaps.filter(i => !i.scan_error); - - assert.equal( - realUatGaps.length, 0, - `UAT with status: resolved must not appear as an open gap; ` + - `got: ${JSON.stringify(realUatGaps)}` - ); - assert.equal(result.counts.uat_gaps, 0); - } finally { - cleanup(cwd); - } - }); - - test('UAT with status: complete remains terminal (no regression)', () => { - const cwd = mkTmp(); - try { - const phaseDir = path.join(cwd, '.planning', 'phases', '02-test'); - fs.mkdirSync(phaseDir, { recursive: true }); - fs.writeFileSync( - path.join(phaseDir, '02-UAT.md'), - '---\nstatus: complete\n---\nUAT body.\n', - 'utf-8' - ); - - const result = auditOpenArtifacts(cwd); - const realUatGaps = result.items.uat_gaps.filter(i => !i.scan_error); - assert.equal(realUatGaps.length, 0); - } finally { - cleanup(cwd); - } - }); - - test('UAT with status: pending is still flagged as an open gap', () => { - const cwd = mkTmp(); - try { - const phaseDir = path.join(cwd, '.planning', 'phases', '03-test'); - fs.mkdirSync(phaseDir, { recursive: true }); - fs.writeFileSync( - path.join(phaseDir, '03-UAT.md'), - '---\nstatus: pending\n---\nresult: pending\n', - 'utf-8' - ); - - const result = auditOpenArtifacts(cwd); - const realUatGaps = result.items.uat_gaps.filter(i => !i.scan_error); - assert.equal(realUatGaps.length, 1, 'pending UAT must still be flagged'); - assert.equal(realUatGaps[0].status, 'pending'); - } finally { - cleanup(cwd); - } - }); - - test('quick task without any SUMMARY file is still flagged as missing', () => { - const cwd = mkTmp(); - try { - const quickId = '260429-test-bar'; - const taskDir = path.join(cwd, '.planning', 'quick', quickId); - fs.mkdirSync(taskDir, { recursive: true }); - // No SUMMARY file at all. - - const result = auditOpenArtifacts(cwd); - const realQuickTasks = result.items.quick_tasks.filter( - i => !i.scan_error && !i._remainder_count - ); - assert.equal(realQuickTasks.length, 1); - assert.equal(realQuickTasks[0].status, 'missing'); - } finally { - cleanup(cwd); - } - }); -}); - -describe('bug #2836: workflows/help.md one-liner reconciliation', () => { - test('help.md quick-task one-liner uses ${quick_id}-SUMMARY.md pattern', () => { - // After #3039, help content moved into help/modes/full.md. - const helpPath = path.resolve( - __dirname, '..', 'gsd-core', 'workflows', 'help', 'modes', 'full.md' - ); - const content = fs.readFileSync(helpPath, 'utf-8'); - - // Locate the documented "Result: Creates ..." quick-task one-liner and - // assert it references the per-task SUMMARY filename pattern, not bare - // SUMMARY.md. We parse by line to avoid false positives elsewhere. - const resultLines = content.split(/\r?\n/).filter(l => - l.includes('Result: Creates') && l.includes('.planning/quick/') - ); - assert.ok(resultLines.length > 0, 'expected a quick-task Result line in help.md'); - for (const line of resultLines) { - assert.ok( - /\$\{quick_id\}-SUMMARY\.md|NNN-slug-SUMMARY\.md/.test(line), - `help.md quick-task Result line must reference per-task SUMMARY filename ` + - `(e.g. \${quick_id}-SUMMARY.md); got: ${line}` - ); - } - }); -}); diff --git a/tests/bug-2851-workflow-bare-gsd-tools.test.cjs b/tests/bug-2851-workflow-bare-gsd-tools.test.cjs deleted file mode 100644 index ae4c0a946..000000000 --- a/tests/bug-2851-workflow-bare-gsd-tools.test.cjs +++ /dev/null @@ -1,205 +0,0 @@ -/** - * Bug #2851: plan-phase.md §13e calls bare `gsd-tools` — incomplete fix of #2245 - * - * Workflow bodies should not call `gsd-tools` as an unguarded bare command. - * Use the resolver snippets for SDK calls, or an explicit local CJS path when - * a command intentionally targets the checked-in legacy script: - * - * node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" [args] - * - * As of #621, the §13e gap-analysis call uses the `gsd_run` launcher (the - * canonical resolvable form) instead of the absolute-$HOME path above. - * Both forms are resolvable; `gsd_run` is now the preferred canonical form. - * - * Some workflow markdown files leaked the bare `gsd-tools ` form, - * which fails with `command not found` at runtime. - * - * This test parses every markdown file in gsd-core/workflows/ structurally: - * it tokenizes the content into fenced code blocks, then on each shell-block - * line checks whether `gsd-tools` appears as a bare command (not preceded by - * `node `, not part of the filename `gsd-tools.cjs`, not inside a comment). - * - * Per project rule: this test does NOT use grep/regex .includes() on raw file - * content as the assertion surface. Instead, it splits into code-fenced blocks - * and tokenizes each line — only command-position tokens count as violations. - */ -'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'); - -/** - * Extract shell-fenced code blocks from a markdown file. - * Returns an array of { startLine, lines } where lines are the contents - * between the ```bash / ```sh / ```shell fence markers. - */ -function extractShellBlocks(content) { - const allLines = content.split('\n'); - const blocks = []; - let inBlock = false; - let blockLang = null; - let blockStart = 0; - let blockLines = []; - - for (let i = 0; i < allLines.length; i++) { - const line = allLines[i]; - const fenceOpen = line.match(/^```(\w+)?/); - if (!inBlock && fenceOpen) { - inBlock = true; - blockLang = (fenceOpen[1] || '').toLowerCase(); - blockStart = i + 2; // 1-indexed line number of first content line - blockLines = []; - continue; - } - if (inBlock && /^```\s*$/.test(line)) { - if (['bash', 'sh', 'shell', 'zsh', ''].includes(blockLang)) { - blocks.push({ startLine: blockStart, lines: blockLines }); - } - inBlock = false; - blockLang = null; - blockLines = []; - continue; - } - if (inBlock) { - blockLines.push(line); - } - } - return blocks; -} - -/** - * Check a single shell-block line for a bare `gsd-tools` command-position token. - * Returns true if the line is a violation. - */ -function lineHasBareGsdTools(line) { - // Strip leading whitespace and any prompt prefix ($ , > , # ) - let l = line.replace(/^\s*[$>]\s*/, ''); - // Skip pure comment lines - if (/^\s*#/.test(l)) return false; - // Strip inline comment (# preceded by whitespace, not inside a string) - // Conservative: only strip if # appears after whitespace and outside quotes — - // we just look for the first ` #` outside of quoted context. For our needs, - // splitting on `^[^"']*?(\s#)` is good enough. - const hashIdx = l.search(/(?:^|[^"'\w])#/); - if (hashIdx > 0) l = l.slice(0, hashIdx); - - // Unwrap command-substitution forms so the substituted command is in - // command position. `$(cmd …)` and `` `cmd …` `` both run the inner string - // as a fresh command, so a bare `gsd-tools` inside them is just as broken - // as one at the start of the line. Iterate until stable for nested forms. - let prev; - do { - prev = l; - l = l.replace(/\$\(([^()]*)\)/g, ' $1 ').replace(/`([^`]*)`/g, ' $1 '); - } while (l !== prev); - - // Tokenize on whitespace, semicolons, pipes, and && / || - // Then walk tokens — a violation is a token that starts with `gsd-tools` - // followed by a word boundary (so `gsd-tools.cjs` does NOT match), and the - // preceding token is NOT `node`. - const segments = l.split(/(?:\s*(?:&&|\|\||;|\|)\s*)/); - for (const seg of segments) { - const tokens = seg.trim().split(/\s+/).filter(Boolean); - if (tokens.length === 0) continue; - // Skip env var assignments at the start (FOO=bar gsd-tools …, tmp=1 gsd-tools …). - // POSIX shell variable names are [A-Za-z_][A-Za-z0-9_]*; lowercase is valid. - let cmdIdx = 0; - while (cmdIdx < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[cmdIdx])) { - cmdIdx++; - } - if (cmdIdx >= tokens.length) continue; - const cmd = tokens[cmdIdx]; - // Match `gsd-tools` exactly (no extension), as command position. - if (cmd === 'gsd-tools') return true; - } - return false; -} - -const AGENTS_DIR = path.join(__dirname, '..', 'agents'); - -describe('bug #1041: agent files must not call bare gsd-tools (all-runtime resolver)', () => { - test('no agents/gsd-*.md file contains a bare gsd-tools command', () => { - const files = fs.readdirSync(AGENTS_DIR).filter((f) => f.startsWith('gsd-') && f.endsWith('.md')); - assert.ok(files.length > 0, 'expected agent files to exist'); - - const violations = []; - for (const f of files) { - const full = path.join(AGENTS_DIR, f); - const content = fs.readFileSync(full, 'utf-8'); - const blocks = extractShellBlocks(content); - for (const blk of blocks) { - for (let i = 0; i < blk.lines.length; i++) { - if (lineHasBareGsdTools(blk.lines[i])) { - violations.push(`${f}:${blk.startLine + i}: ${blk.lines[i].trim()}`); - } - } - } - } - - assert.deepStrictEqual( - violations, - [], - 'Bare `gsd-tools` invocations found in agent shell blocks. ' + - 'Inject the runtime-launcher preamble (_runtime-launcher.snippet.sh) and use `gsd_run` instead.\n' + - violations.join('\n'), - ); - }); -}); - -describe('bug-2851: workflow files must not call bare `gsd-tools` (#2245 sweep regression)', () => { - test('no gsd-core/workflows/*.md file contains a bare gsd-tools command', () => { - const files = fs.readdirSync(WORKFLOWS_DIR).filter((f) => f.endsWith('.md')); - assert.ok(files.length > 0, 'expected workflow files to exist'); - - const violations = []; - for (const f of files) { - const full = path.join(WORKFLOWS_DIR, f); - const content = fs.readFileSync(full, 'utf-8'); - const blocks = extractShellBlocks(content); - for (const blk of blocks) { - for (let i = 0; i < blk.lines.length; i++) { - if (lineHasBareGsdTools(blk.lines[i])) { - violations.push(`${f}:${blk.startLine + i}: ${blk.lines[i].trim()}`); - } - } - } - } - - assert.deepStrictEqual( - violations, - [], - 'Bare `gsd-tools` invocations found in workflow shell blocks. ' + - 'Use a resolver snippet or `node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" ` instead.\n' + - violations.join('\n'), - ); - }); - - test('plan-phase.md §13e gap-analysis dispatches via gsd_run loop render-hooks plan:post (ADR-857 capability gate, #621)', () => { - const planPhase = fs.readFileSync(path.join(WORKFLOWS_DIR, 'plan-phase.md'), 'utf-8'); - const blocks = extractShellBlocks(planPhase); - let foundPlanPostDispatch = false; - for (const blk of blocks) { - for (const line of blk.lines) { - if (/gsd_run\s+loop\s+render-hooks\s+plan:post\s+--raw/.test(line) && !/^\s*#/.test(line)) { - foundPlanPostDispatch = true; - } - } - } - assert.ok( - foundPlanPostDispatch, - 'expected plan-phase.md §13e to dispatch gsd_run loop render-hooks plan:post --raw (gap-analysis moved to capability gate plan:post in ADR-857 migration)', - ); - const registry = require('../gsd-core/bin/lib/capability-registry.cjs'); - const planPostPoint = (registry.byLoopPoint || {})['plan:post'] || {}; - const gates = planPostPoint.gates || []; - const gapAnalysisGate = gates.find((g) => g.capId === 'gap-analysis'); - assert.ok( - gapAnalysisGate, - 'gap-analysis capability must be registered as a plan:post gate in capability-registry.cjs', - ); - }); -}); diff --git a/tests/bug-2943-config-get-context-window-default.test.cjs b/tests/bug-2943-config-get-context-window-default.test.cjs deleted file mode 100644 index 700e1e84e..000000000 --- a/tests/bug-2943-config-get-context-window-default.test.cjs +++ /dev/null @@ -1,146 +0,0 @@ -/** - * Regression test for bug #2943 - * - * `gsd-tools.cjs config-get context_window` (and the SDK equivalent) threw - * "Key not found: context_window" when the key was absent from config.json, - * even though context_window has a documented schema default of 200000. - * - * Fix: `cmdConfigGet` in bin/lib/config.cjs now consults a SCHEMA_DEFAULTS map - * before emitting "Key not found", so schema-defaulted keys always return the - * default value (exit 0) when not explicitly set in the project config. - */ - -'use strict'; - -// Migrated to typed-IR (#2974): the previous shape grepped stderr/stdout for -// "Key not found"; now the test passes `--json-errors` to gsd-tools and -// asserts on the structured `reason` code (a frozen-enum value from -// `core.cjs::ERROR_REASON`). Exit code is also a typed signal — together -// they fully discriminate the failure class. - -const { describe, test, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const os = require('node:os'); -const { execFileSync } = require('node:child_process'); - -const GSD_TOOLS = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs'); -const { ERROR_REASON } = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'io.cjs')); -const { cleanup } = require('./helpers.cjs'); - -describe('bug-2943: config-get returns schema default for context_window', () => { - let tmpDir; - let planningDir; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-2943-')); - planningDir = path.join(tmpDir, '.planning'); - fs.mkdirSync(planningDir, { recursive: true }); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - /** - * Run config-get with optional extra args. Returns { exitCode, stdout, stderr }. - * Uses --raw so we get the plain scalar value, not JSON-wrapped. - */ - function runConfigGet(keyPath, extraArgs = []) { - const args = [GSD_TOOLS, 'config-get', keyPath, '--raw', '--cwd', tmpDir, ...extraArgs]; - let stdout = ''; - let stderr = ''; - let exitCode = 0; - try { - // Windows/Node 22 under --test-concurrency=4 can starve subprocess slots when - // sharing a wave with bug-2760-codex-install (8–15s install subtests). 15s covers - // observed worst case (13.5s) with headroom. - stdout = execFileSync(process.execPath, args, { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - timeout: 15000, - }); - } catch (err) { - exitCode = err.status ?? 1; - stdout = err.stdout?.toString() ?? ''; - stderr = err.stderr?.toString() ?? ''; - } - return { exitCode, stdout: stdout.trim(), stderr: stderr.trim() }; - } - - test('returns "200000" (exit 0) when context_window absent from config.json', () => { - // Fixture A: config with unrelated keys, no context_window - fs.writeFileSync( - path.join(planningDir, 'config.json'), - JSON.stringify({ workflow: { auto_advance: false } }) - ); - - const result = runConfigGet('context_window'); - - assert.strictEqual(result.exitCode, 0, 'should exit 0 (schema default applied)'); - assert.strictEqual(result.stdout, '200000', 'should return schema default of 200000'); - }); - - test('returns configured value when context_window is explicitly set', () => { - // Fixture B: config has context_window: 1000000 - fs.writeFileSync( - path.join(planningDir, 'config.json'), - JSON.stringify({ context_window: 1000000 }) - ); - - const result = runConfigGet('context_window'); - - assert.strictEqual(result.exitCode, 0, 'should exit 0 for found key'); - assert.strictEqual(result.stdout, '1000000', 'should return configured value not schema default'); - }); - - test('--default flag overrides schema default', () => { - // config has context_window but we pass --default with a different value — - // when key IS present, real value wins over any default - fs.writeFileSync( - path.join(planningDir, 'config.json'), - JSON.stringify({ workflow: { auto_advance: false } }) - ); - - const result = runConfigGet('context_window', ['--default', '123456']); - - assert.strictEqual(result.exitCode, 0, 'should exit 0 when --default provided'); - assert.strictEqual(result.stdout, '123456', 'should return the --default value, not schema default'); - }); - - test('errors with reason=CONFIG_KEY_NOT_FOUND (exit 1) for an unknown absent key — no regression', () => { - // An unrecognised key with no schema default still errors as before. - // Migrated #2974: assert on the structured reason code from --json-errors, - // not on substring presence in stderr/stdout text. - fs.writeFileSync( - path.join(planningDir, 'config.json'), - JSON.stringify({ workflow: { auto_advance: false } }) - ); - - const result = runConfigGet('totally_unknown_key_xyz', ['--json-errors']); - - assert.strictEqual(result.exitCode, 1, 'should exit 1 for unknown absent key'); - let parsed; - try { - parsed = JSON.parse(result.stderr); - } catch (err) { - assert.fail(`expected JSON-shaped stderr from --json-errors; got: ${JSON.stringify(result.stderr)}`); - } - assert.strictEqual(parsed.ok, false); - assert.strictEqual(parsed.reason, ERROR_REASON.CONFIG_KEY_NOT_FOUND, - `expected reason=${ERROR_REASON.CONFIG_KEY_NOT_FOUND}, got=${parsed.reason}`); - }); - - test('--default flag still works for arbitrary absent keys', () => { - fs.writeFileSync( - path.join(planningDir, 'config.json'), - JSON.stringify({}) - ); - - const result = runConfigGet('some.missing.key', ['--default', '200000']); - - assert.strictEqual(result.exitCode, 0, 'should exit 0 when --default supplied'); - assert.strictEqual(result.stdout, '200000', 'should return the explicit --default value'); - }); -}); diff --git a/tests/bug-2948-spike-wrap-up-dispatch.test.cjs b/tests/bug-2948-spike-wrap-up-dispatch.test.cjs deleted file mode 100644 index 74d4994d6..000000000 --- a/tests/bug-2948-spike-wrap-up-dispatch.test.cjs +++ /dev/null @@ -1,162 +0,0 @@ -/** - * Regression test for bug #2948 - * - * `/gsd:spike --wrap-up` was silently no-oping because: - * 1. `commands/gsd/spike.md` listed `--wrap-up` as a flag but had no dispatch block. - * 2. `workflows/spike.md` still referenced the deleted `/gsd-spike-wrap-up` entry-point - * instead of the correct `/gsd:spike --wrap-up` form. - * - * Fix: - * - `commands/gsd/spike.md` now has a dispatch block that routes `--wrap-up` to - * spike-wrap-up.md, and spike-wrap-up.md is listed in execution_context so the - * runtime can find it. - * - `workflows/spike.md` companion references updated from `/gsd-spike-wrap-up` to - * `/gsd:spike --wrap-up`. - */ - -// allow-test-rule: source-text-is-the-product -// commands/gsd/*.md files ARE what the runtime loads — testing their -// frontmatter and section content tests the deployed system-prompt contract. - -'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 SPIKE_CMD_PATH = path.join(__dirname, '..', 'commands', 'gsd', 'spike.md'); -const SPIKE_WORKFLOW_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'spike.md'); - -/** - * Parse YAML frontmatter + body from a markdown file. - * Returns a shallow { key: value } map of frontmatter fields plus `_body`. - * Mirrors the parseFrontmatter utility used in enh-2792-namespace-skills.test.cjs. - */ -function parseFrontmatter(content) { - const lines = content.split(/\r?\n/); - - // Frontmatter must start at the very first line; a mid-file '---' is a - // horizontal rule, not a frontmatter delimiter. - if (lines[0]?.trim() !== '---') { - return { _body: content }; - } - - let closeIdx = -1; - for (let i = 1; i < lines.length; i += 1) { - if (lines[i].trim() === '---') { - closeIdx = i; - break; - } - } - assert.ok(closeIdx !== -1, 'frontmatter block must be delimited by --- on its own lines'); - const fm = {}; - for (const line of lines.slice(1, closeIdx)) { - const m = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/); - if (!m) continue; - const [, key, raw] = m; - fm[key] = raw.trim().replace(/^["']|["']$/g, ''); - } - fm._body = lines.slice(closeIdx + 1).join('\n'); - return fm; -} - -/** - * Extract the text content of a named XML-like section from a markdown body. - * Returns null if the section is absent. - */ -function extractSection(body, tag) { - const open = `<${tag}>`; - const close = ``; - const start = body.indexOf(open); - const end = body.indexOf(close); - if (start === -1 || end === -1) return null; - return body.slice(start + open.length, end); -} - -/** - * Parse the @-prefixed workflow references out of an execution_context section. - * Returns an array of resolved reference strings (@ stripped). - */ -function parseExecutionContextRefs(section) { - return section - .split(/\r?\n/) - .map(l => l.trim()) - .filter(l => l.startsWith('@')) - .map(l => l.slice(1).trim()); -} - -describe('bug-2948: /gsd:spike --wrap-up dispatch wiring', () => { - describe('commands/gsd/spike.md — frontmatter and section contract', () => { - test('spike.md command file exists and has valid frontmatter', () => { - assert.ok(fs.existsSync(SPIKE_CMD_PATH), 'commands/gsd/spike.md should exist'); - const fm = parseFrontmatter(fs.readFileSync(SPIKE_CMD_PATH, 'utf-8')); - assert.ok(fm.name, 'frontmatter must have a name field'); - }); - - test('argument-hint frontmatter field advertises --wrap-up flag', () => { - const fm = parseFrontmatter(fs.readFileSync(SPIKE_CMD_PATH, 'utf-8')); - assert.ok( - fm['argument-hint'] && fm['argument-hint'].includes('--wrap-up'), - `argument-hint must advertise --wrap-up; got: "${fm['argument-hint']}"` - ); - }); - - test('execution_context section includes spike-wrap-up workflow reference', () => { - const fm = parseFrontmatter(fs.readFileSync(SPIKE_CMD_PATH, 'utf-8')); - const execSection = extractSection(fm._body, 'execution_context'); - assert.ok(execSection !== null, 'spike.md must have an section'); - const refs = parseExecutionContextRefs(execSection); - assert.ok( - refs.some(r => r.includes('spike-wrap-up')), - `execution_context must declare a spike-wrap-up reference so the runtime can load the workflow; ` + - `declared refs: ${JSON.stringify(refs)}` - ); - }); - - test('process section dispatches first-token --wrap-up to spike-wrap-up workflow', () => { - const fm = parseFrontmatter(fs.readFileSync(SPIKE_CMD_PATH, 'utf-8')); - const processSection = extractSection(fm._body, 'process'); - assert.ok(processSection, 'spike.md must have a section'); - - const rules = processSection - .split(/\r?\n/) - .map(line => line.trim()) - .filter(Boolean); - - const wrapUpRule = rules.find(line => line.startsWith('- If it is `--wrap-up`:')); - const fallbackRule = rules.find(line => line.startsWith('- Otherwise:')); - - assert.ok( - wrapUpRule && wrapUpRule.includes('strip the flag') && wrapUpRule.includes('spike-wrap-up'), - 'process must define a --wrap-up branch that strips the flag and routes to spike-wrap-up' - ); - assert.ok( - fallbackRule && fallbackRule.includes('spike workflow'), - 'process must define an Otherwise fallback to the normal spike workflow' - ); - }); - }); - - describe('gsd-core/workflows/spike.md — companion references', () => { - test('spike workflow file exists', () => { - assert.ok(fs.existsSync(SPIKE_WORKFLOW_PATH), 'gsd-core/workflows/spike.md should exist'); - }); - - test('does NOT reference the deleted /gsd-spike-wrap-up entry-point', () => { - const fm = parseFrontmatter(fs.readFileSync(SPIKE_WORKFLOW_PATH, 'utf-8')); - assert.ok( - !fm._body.includes('/gsd-spike-wrap-up'), - 'workflows/spike.md must not reference the deleted /gsd-spike-wrap-up command; use /gsd:spike --wrap-up instead' - ); - }); - - test('references /gsd:spike --wrap-up as the canonical wrap-up invocation', () => { - const fm = parseFrontmatter(fs.readFileSync(SPIKE_WORKFLOW_PATH, 'utf-8')); - assert.ok( - fm._body.includes('/gsd:spike --wrap-up'), - 'workflows/spike.md must reference /gsd:spike --wrap-up as the canonical wrap-up command' - ); - }); - }); -}); diff --git a/tests/bug-2949-sketch-wrap-up-dispatch.test.cjs b/tests/bug-2949-sketch-wrap-up-dispatch.test.cjs deleted file mode 100644 index 734bb857b..000000000 --- a/tests/bug-2949-sketch-wrap-up-dispatch.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. - -/** - * GSD Tests — /gsd:sketch --wrap-up silently no-ops (#2949) - * - * The --wrap-up flag was documented in commands/gsd/sketch.md but never dispatched. - * The sketch-wrap-up.md micro-skill entry point was deleted in #2790 and the dispatch - * wiring was never added to the command or workflow. - */ - -'use strict'; - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const ROOT = path.resolve(__dirname, '..'); -const SKETCH_COMMAND = path.join(ROOT, 'commands/gsd/sketch.md'); -const SKETCH_WORKFLOW = path.join(ROOT, 'gsd-core/workflows/sketch.md'); - -describe('bug-2949: sketch --wrap-up dispatch wiring', () => { - test('commands/gsd/sketch.md contains --wrap-up dispatch logic', () => { - const content = fs.readFileSync(SKETCH_COMMAND, 'utf8'); - assert.ok( - content.includes('--wrap-up'), - 'sketch.md should contain --wrap-up dispatch logic' - ); - // The dispatch should route to sketch-wrap-up workflow - assert.ok( - content.includes('sketch-wrap-up'), - 'sketch.md should reference sketch-wrap-up in dispatch logic' - ); - }); - - test('commands/gsd/sketch.md has sketch-wrap-up in execution_context section', () => { - const content = fs.readFileSync(SKETCH_COMMAND, 'utf8'); - // Find execution_context block - const execCtxMatch = content.match(/([\s\S]*?)<\/execution_context>/); - assert.ok(execCtxMatch, 'sketch.md must have an block'); - const execCtx = execCtxMatch[1]; - assert.ok( - execCtx.includes('sketch-wrap-up'), - `execution_context block should include sketch-wrap-up workflow; got: ${execCtx}` - ); - }); - - test('workflows/sketch.md does NOT contain old /gsd-sketch-wrap-up form', () => { - const content = fs.readFileSync(SKETCH_WORKFLOW, 'utf8'); - assert.ok( - !content.includes('/gsd-sketch-wrap-up'), - 'workflows/sketch.md must not reference the old /gsd-sketch-wrap-up command' - ); - }); - - test('workflows/sketch.md DOES contain new /gsd:sketch --wrap-up form', () => { - const content = fs.readFileSync(SKETCH_WORKFLOW, 'utf8'); - assert.ok( - content.includes('/gsd:sketch --wrap-up'), - 'workflows/sketch.md should reference /gsd:sketch --wrap-up (the new form)' - ); - }); -}); diff --git a/tests/bug-2954-help-md-slash-command-stubs.test.cjs b/tests/bug-2954-help-md-slash-command-stubs.test.cjs deleted file mode 100644 index 2287225c1..000000000 --- a/tests/bug-2954-help-md-slash-command-stubs.test.cjs +++ /dev/null @@ -1,183 +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'; - - -process.env.GSD_TEST_MODE = '1'; - -/** - * Bug #2954: keep `help.md` and the live `commands/gsd/*` slash surface - * in lockstep. Two regression tests: - * - * 1. help.md must not advertise any /gsd[-:] that has no shipped - * slash command. (Caught the original #2954 regression: #2824 deleted - * 31 stubs without updating help.md.) - * - * 2. Every shipped /gsd[-:] command must appear in help.md. (Caught - * the inverse: a command lands without docs, so users never discover it.) - * - * The shipped slash name is parsed from frontmatter `name:` (which can be - * either `gsd:foo` or `gsd-foo` — Claude Code surfaces both as `/gsd-foo`), - * NOT from the filename, because some files (e.g. `ns-context.md`) ship a - * different slash name (`gsd-context`) than their filename suggests. - * - * Also covers `do.md`, the dispatcher invoked at runtime by - * `/gsd:progress --do`: any `/gsd[-:]` token in its routing table must - * resolve to a live command, otherwise the dispatcher emits "Unknown command". - */ - -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 COMMANDS_DIR = path.join(ROOT, 'commands', 'gsd'); -// After #3039, the canonical command reference is the `--full` mode file. -// `workflows/help.md` is now a small dispatcher; the bidirectional parity -// invariant lives with the comprehensive reference body. -const HELP_MD = path.join(ROOT, 'gsd-core', 'workflows', 'help', 'modes', 'full.md'); -const DO_MD = path.join(ROOT, 'gsd-core', 'workflows', 'do.md'); - -function parseFrontmatter(content) { - const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); - if (!match) return null; - const fields = {}; - for (const line of match[1].split(/\r?\n/)) { - const fieldMatch = line.match(/^([a-zA-Z0-9_-]+):\s*(.*)$/); - if (!fieldMatch) continue; - const value = fieldMatch[2].trim().replace(/^["']|["']$/g, ''); - fields[fieldMatch[1]] = value; - } - return fields; -} - -/** - * Returns the set of slash-base-names actually shipped under commands/gsd/. - * A "slash-base-name" is the part after `/gsd-` — e.g. for frontmatter - * `name: gsd:foo` or `name: gsd-foo`, the slash-base-name is `foo`. - */ -function listShippedSlashBaseNames() { - const names = new Set(); - for (const entry of fs.readdirSync(COMMANDS_DIR, { withFileTypes: true })) { - if (!entry.isFile() || !entry.name.endsWith('.md')) continue; - const content = fs.readFileSync(path.join(COMMANDS_DIR, entry.name), 'utf8'); - const fm = parseFrontmatter(content); - if (!fm || !fm.name) continue; - const fmName = fm.name; - let base = null; - if (fmName.startsWith('gsd:')) base = fmName.slice(4); - else if (fmName.startsWith('gsd-')) base = fmName.slice(4); - if (base && /^[a-z][a-z0-9-]*$/.test(base)) names.add(base); - } - return names; -} - -function extractSlashReferences(contents) { - const names = new Set(); - // Negative lookbehind: must not be preceded by a letter (avoids matching npm scope - // paths like @opengsd/gsd-core where `/gsd-` appears inside a package URL). - // Negative lookahead (?![\w-]*\/): excludes filesystem path segments like - // `/gsd-core/bin` where the captured name is followed by a `/`, which would - // be a directory segment rather than a slash command name. - const tokenRe = /(?>. Flags are recorded without their leading `--`. - */ -function listShippedFlagsByCommand() { - const out = new Map(); - for (const entry of fs.readdirSync(COMMANDS_DIR, { withFileTypes: true })) { - if (!entry.isFile() || !entry.name.endsWith('.md')) continue; - const content = fs.readFileSync(path.join(COMMANDS_DIR, entry.name), 'utf8'); - const fm = parseFrontmatter(content); - if (!fm || !fm.name || !fm['argument-hint']) continue; - const fmName = fm.name; - let base = null; - if (fmName.startsWith('gsd:')) base = fmName.slice(4); - else if (fmName.startsWith('gsd-')) base = fmName.slice(4); - if (!base || !/^[a-z][a-z0-9-]*$/.test(base)) continue; - const flags = new Set(); - for (const m of fm['argument-hint'].matchAll(/--([a-z][a-z0-9-]*)/g)) { - flags.add(m[1]); - } - if (flags.size) out.set(base, flags); - } - return out; -} - -describe('Bug #2954: help.md ↔ commands/gsd/ bidirectional parity', () => { - test('every /gsd[-:] referenced in help.md is a shipped command', () => { - const helpContents = fs.readFileSync(HELP_MD, 'utf8'); - const referenced = extractSlashReferences(helpContents); - const shipped = listShippedSlashBaseNames(); - const dangling = [...referenced].filter((n) => !shipped.has(n)).sort(); - assert.deepEqual( - dangling, - [], - `help.md advertises /gsd[-:] commands that are not shipped: ${dangling.join(', ')}`, - ); - }); - - test('every shipped /gsd[-:] command is documented in help.md', () => { - const helpContents = fs.readFileSync(HELP_MD, 'utf8'); - const referenced = extractSlashReferences(helpContents); - const shipped = listShippedSlashBaseNames(); - const undocumented = [...shipped].filter((n) => !referenced.has(n)).sort(); - assert.deepEqual( - undocumented, - [], - `commands shipped under commands/gsd/ with no /gsd[-:] reference in help.md: ${undocumented.join(', ')}`, - ); - }); - - test('every /gsd[-:] in do.md (live dispatcher) is a shipped command', () => { - const doContents = fs.readFileSync(DO_MD, 'utf8'); - const referenced = extractSlashReferences(doContents); - const shipped = listShippedSlashBaseNames(); - const dangling = [...referenced].filter((n) => !shipped.has(n)).sort(); - assert.deepEqual( - dangling, - [], - `do.md routing table references /gsd[-:] that is not shipped: ${dangling.join(', ')}`, - ); - }); - - test('every --flag in a command\'s argument-hint appears in help.md', () => { - const helpContents = fs.readFileSync(HELP_MD, 'utf8'); - const flagsByCommand = listShippedFlagsByCommand(); - const gaps = []; - for (const [command, flags] of flagsByCommand) { - for (const flag of flags) { - // Accept `/gsd- --` (precise) OR a bare `--` token - // anywhere in help.md (good enough for shared flags like `--force` that - // appear under multiple commands' descriptions). - const preciseDash = `/gsd-${command} --${flag}`; - const preciseColon = `/gsd:${command} --${flag}`; - const flagToken = `--${flag}`; - if ( - !helpContents.includes(preciseDash) && - !helpContents.includes(preciseColon) && - !helpContents.includes(flagToken) - ) { - gaps.push(`/gsd:${command} --${flag}`); - } - } - } - assert.deepEqual( - gaps.sort(), - [], - `commands ship --flag(s) in argument-hint that are absent from help.md: ${gaps.join(', ')}`, - ); - }); -}); diff --git a/tests/bug-2986-config-schema-mutation-killers.test.cjs b/tests/bug-2986-config-schema-mutation-killers.test.cjs deleted file mode 100644 index 5f24ef406..000000000 --- a/tests/bug-2986-config-schema-mutation-killers.test.cjs +++ /dev/null @@ -1,145 +0,0 @@ -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -/** - * Bug #2986: Layer-3 fault-detection audit found 4.62% Stryker mutation - * score on gsd-core/bin/lib/config-schema.cjs (6 killed, 124 survived). - * Surviving mutants document tests that "exercise paths" but don't - * "verify outputs" -- a polarity flip or predicate swap inside the lib - * passed every existing test. - * - * Sample surviving mutants from #2986: - * M1: `if (VALID_CONFIG_KEYS.has(keyPath)) return true;` - * -> `if (false) return true;` - * Killer: a test that asserts isValidConfigKey returns true for - * every member of VALID_CONFIG_KEYS. If VALID_CONFIG_KEYS.has is - * short-circuited to false, those keys would only be accepted if - * a DYNAMIC_KEY_PATTERN matches them -- and none of the static - * keys match any dynamic pattern by design. - * - * M2: `return DYNAMIC_KEY_PATTERNS.some((p) => p.test(keyPath));` - * -> `return DYNAMIC_KEY_PATTERNS.every(p => p.test(keyPath));` - * Killer: a test that supplies a key matching ONE pattern but not - * every pattern. With `.every`, that key is rejected; with `.some`, - * accepted. The current dynamic-pattern set is mutually exclusive - * (e.g., `agent_skills.foo` matches the agent_skills regex but not - * review/features/claude_md_assembly/model_profile_overrides), so - * any single dynamic-key sample suffices. - * - * M3: `return true` -> `return false` on the early-return line - * Killer: a test that uses a known-valid static key and asserts - * the boolean true (not just "non-falsy" or "no throw"). A - * polarity flip turns the true into false; the assertion catches it. - * - * M4: `if (VALID_CONFIG_KEYS.has(keyPath)) return true;` -> remove the - * guard entirely (return DYNAMIC_KEY_PATTERNS.some(...) always). - * Killer: same as M1 -- static keys that don't match any dynamic - * pattern would be wrongly rejected. - * - * These tests exercise the lib's PUBLIC SURFACE (isValidConfigKey) - * with structured inputs and assert on typed boolean outputs. No regex - * on source code; no source-grep. - */ - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const { - VALID_CONFIG_KEYS, - DYNAMIC_KEY_PATTERNS, - isValidConfigKey, -} = require('../gsd-core/bin/lib/config-schema.cjs'); - -describe('Bug #2986: M1/M4 -- isValidConfigKey returns true for EVERY static key in VALID_CONFIG_KEYS', () => { - // Stryker mutants like `if (false) return true;` would silently flip - // every static key to "rejected" because none of the static keys match - // any dynamic pattern by design. This parameterized test is the - // mutation-kill equivalent for that branch. - for (const key of VALID_CONFIG_KEYS) { - test(`isValidConfigKey('${key}') === true`, () => { - assert.strictEqual(isValidConfigKey(key), true, - `static config key '${key}' must be accepted (catches Stryker mutant on the static-key fast path)`); - }); - } -}); - -describe('Bug #2986: M2 -- DYNAMIC_KEY_PATTERNS.some semantic, not .every', () => { - // Each pattern has a representative key that matches ONLY that pattern - // (mutually exclusive with the others by design) AND is NOT a member of - // VALID_CONFIG_KEYS. The static-key fast-path returns true before - // DYNAMIC_KEY_PATTERNS.some() ever runs, so any rep key that's also in - // VALID_CONFIG_KEYS gives the M2 killer zero coverage for that pattern - // (#3005 CR: this caught features.thinking_partner, which IS in static). - // A reserved-prefix-style placeholder name is used for `features` so the - // dynamic path is the only way to reach `true`. - const patternRepresentatives = [ - { key: 'agent_skills.gsd-planner', topLevel: 'agent_skills' }, - { key: 'review.models.claude', topLevel: 'review' }, - { key: 'features.some_dynamic_feature', topLevel: 'features' }, - { key: 'claude_md_assembly.blocks.intro', topLevel: 'claude_md_assembly' }, - { key: 'model_profile_overrides.codex.opus', topLevel: 'model_profile_overrides' }, - ]; - - for (const { key, topLevel } of patternRepresentatives) { - test(`isValidConfigKey('${key}') === true (matches '${topLevel}' pattern via dynamic path)`, () => { - // Invariant: the rep key MUST NOT be in the static set. Otherwise the - // static fast-path short-circuits and the dynamic-pattern .some() is - // never invoked, so a mutation removing this entry from - // DYNAMIC_KEY_PATTERNS would survive. - assert.strictEqual(VALID_CONFIG_KEYS.has(key), false, - `representative key '${key}' must NOT be in VALID_CONFIG_KEYS — otherwise the static fast-path masks the dynamic-pattern test (#3005 CR)`); - assert.strictEqual(isValidConfigKey(key), true, - `dynamic key '${key}' must be accepted via DYNAMIC_KEY_PATTERNS.some`); - // Verify mutual exclusivity: only one pattern matches this key. - const matchCount = DYNAMIC_KEY_PATTERNS.filter((p) => p.test(key)).length; - assert.strictEqual(matchCount, 1, - `mutual-exclusivity invariant: '${key}' must match exactly 1 pattern, matched ${matchCount}. ` + - `If this fails, dynamic-pattern overlap was introduced and the .some-vs-.every mutation killer breaks.`); - }); - } -}); - -describe('Bug #2986: M3 -- polarity assertion (true is true, not just truthy)', () => { - // Stryker mutants that flip `return true` to `return false` are killed - // by strictEqual against the boolean true. assert.ok would tolerate any - // truthy value (e.g., a non-empty string returned by a different mutation). - test('isValidConfigKey returns the literal boolean true for static keys', () => { - const result = isValidConfigKey('model_profile'); - assert.strictEqual(result, true); - assert.strictEqual(typeof result, 'boolean'); - }); - - test('isValidConfigKey returns the literal boolean false for unknown keys', () => { - const result = isValidConfigKey('totally_unknown_key_xyz'); - assert.strictEqual(result, false); - assert.strictEqual(typeof result, 'boolean'); - }); - - test('isValidConfigKey returns false for a dynamic-pattern-shape key under a non-existent topLevel', () => { - // E.g., `unrelated.models.claude` syntactically resembles a dynamic - // pattern but no DYNAMIC_KEY_PATTERN owns the `unrelated` topLevel. - // A mutant that loosens the regex anchors would falsely accept this. - assert.strictEqual(isValidConfigKey('unrelated.models.claude'), false); - }); -}); - -describe('Bug #2986: anchor-tightening (catches mutants that loosen ^ or $ in regexes)', () => { - // Each dynamic regex is anchored. Mutants that drop ^ or $ would - // accept too much. These keys differ from a valid one by ONE character - // beyond the documented shape; they must be rejected. - const overshoot = [ - { key: 'agent_skills.gsd-planner.extra', reason: 'agent_skills regex must not allow trailing dot-segment' }, - { key: 'agent_skills.', reason: 'agent_skills regex requires non-empty agent name' }, - { key: 'review.models.', reason: 'review.models regex requires non-empty cli name' }, - { key: 'features.bad name with spaces', reason: 'features regex disallows spaces' }, - { key: 'model_profile_overrides.codex.gpt5', reason: 'model_profile_overrides tier is enum-restricted to opus|sonnet|haiku' }, - { key: 'model_profile_overrides.codex', reason: 'model_profile_overrides requires . suffix' }, - ]; - - for (const { key, reason } of overshoot) { - test(`isValidConfigKey('${key}') === false -- ${reason}`, () => { - assert.strictEqual(isValidConfigKey(key), false, - `'${key}' must be rejected (catches anchor/charset-loosening mutants)`); - }); - } -}); diff --git a/tests/bug-3126-global-skills-base-runtime-path.test.cjs b/tests/bug-3126-global-skills-base-runtime-path.test.cjs deleted file mode 100644 index 2a67ddc30..000000000 --- a/tests/bug-3126-global-skills-base-runtime-path.test.cjs +++ /dev/null @@ -1,346 +0,0 @@ -'use strict'; -// allow-test-rule: last three tests read init.cjs source to verify delegation contract to runtime-homes.cjs — structural guard, no behavioral IR exposed - -// Regression guard for bug #3126. -// -// buildAgentSkillsBlock() in init.cjs hardcoded `globalSkillsBase` to -// `~/.claude/skills` regardless of the active runtime. On a Cursor install, -// global: skills live under `~/.cursor/skills`, causing every global: lookup -// to silently fail with: -// [agent-skills] WARNING: Global skill not found at "~/.cursor/skills/X/SKILL.md" — skipping -// -// Fix introduces gsd-core/bin/lib/runtime-homes.cjs with first-class -// support for all 15 supported runtimes, including: -// - hermes: nested skills/gsd// layout (#2841) -// - cline: rules-based, returns null (no skills directory) -// - CLAUDE_CONFIG_DIR env var for Claude (was missing) -// - All other runtime-specific env vars - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const path = require('node:path'); -const os = require('node:os'); -const { cleanup } = require('./helpers.cjs'); - -const ROOT = path.join(__dirname, '..'); -const { - getGlobalConfigDir, - getGlobalSkillsBase, - getGlobalSkillDir, -} = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-homes.cjs')); - -// Helper: run fn with an env var temporarily set -function withEnv(key, value, fn) { - const orig = process.env[key]; - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - try { return fn(); } - finally { - if (orig === undefined) delete process.env[key]; - else process.env[key] = orig; - } -} - -describe('bug #3126: runtime-homes getGlobalConfigDir — defaults', () => { - const defaults = [ - ['claude', path.join(os.homedir(), '.claude')], - ['cursor', path.join(os.homedir(), '.cursor')], - ['gemini', path.join(os.homedir(), '.gemini')], - ['codex', path.join(os.homedir(), '.codex')], - ['copilot', path.join(os.homedir(), '.copilot')], - ['antigravity', path.join(os.homedir(), '.gemini', 'antigravity')], - ['windsurf', path.join(os.homedir(), '.codeium', 'windsurf')], - ['augment', path.join(os.homedir(), '.augment')], - ['trae', path.join(os.homedir(), '.trae')], - ['qwen', path.join(os.homedir(), '.qwen')], - ['hermes', path.join(os.homedir(), '.hermes')], - ['codebuddy', path.join(os.homedir(), '.codebuddy')], - ['cline', path.join(os.homedir(), '.cline')], - ['opencode', path.join(os.homedir(), '.config', 'opencode')], - ['kilo', path.join(os.homedir(), '.config', 'kilo')], - ]; - for (const [runtime, expected] of defaults) { - test(`${runtime} default configDir`, () => { - // Derive env-var list from the registry so new runtimes are auto-covered. - // GROK_AGENTS_HOME is kept explicitly (grok has no registry entry). - const { runtimes: _reg3126 } = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'capability-registry.cjs')); - const _regEnvKeys3126 = Object.values(_reg3126).flatMap((r) => { - const ch = r.runtime?.configHome; - if (!ch) return []; - const envs = Array.isArray(ch.env) ? ch.env : []; - const skillsEnvs = ch.skillsHome && Array.isArray(ch.skillsHome.env) ? ch.skillsHome.env : []; - return [...envs, ...skillsEnvs]; - }); - const envKeys = [...new Set([..._regEnvKeys3126, 'GROK_AGENTS_HOME', 'XDG_CONFIG_HOME'])]; - const saved = {}; - for (const k of envKeys) { saved[k] = process.env[k]; delete process.env[k]; } - try { - assert.strictEqual(getGlobalConfigDir(runtime), expected); - } finally { - for (const k of envKeys) { - if (saved[k] !== undefined) process.env[k] = saved[k]; - } - } - }); - } - test('unknown runtime falls back to ~/.claude', () => { - withEnv('CLAUDE_CONFIG_DIR', undefined, () => { - assert.strictEqual(getGlobalConfigDir('unknown-xyz'), path.join(os.homedir(), '.claude')); - }); - }); -}); - -describe('bug #3126: runtime-homes env-var overrides', () => { - test('claude respects CLAUDE_CONFIG_DIR (was missing in old code)', () => { - withEnv('CLAUDE_CONFIG_DIR', '/custom/claude', () => { - assert.strictEqual(String(getGlobalConfigDir('claude')).replace(/\\/g, '/'), '/custom/claude'); - }); - }); - test('cursor respects CURSOR_CONFIG_DIR', () => { - withEnv('CURSOR_CONFIG_DIR', '/custom/cursor', () => { - assert.strictEqual(String(getGlobalConfigDir('cursor')).replace(/\\/g, '/'), '/custom/cursor'); - }); - }); - test('opencode respects OPENCODE_CONFIG_DIR', () => { - withEnv('OPENCODE_CONFIG_DIR', '/custom/opencode', () => { - withEnv('XDG_CONFIG_HOME', undefined, () => { - assert.strictEqual(String(getGlobalConfigDir('opencode')).replace(/\\/g, '/'), '/custom/opencode'); - }); - }); - }); - test('opencode uses XDG_CONFIG_HOME when OPENCODE_CONFIG_DIR absent', () => { - withEnv('OPENCODE_CONFIG_DIR', undefined, () => { - withEnv('OPENCODE_CONFIG', undefined, () => { - withEnv('XDG_CONFIG_HOME', '/xdg', () => { - assert.strictEqual(getGlobalConfigDir('opencode'), path.join('/xdg', 'opencode')); - }); - }); - }); - }); - test('kilo uses XDG_CONFIG_HOME when KILO_CONFIG_DIR absent', () => { - withEnv('KILO_CONFIG_DIR', undefined, () => { - withEnv('KILO_CONFIG', undefined, () => { - withEnv('XDG_CONFIG_HOME', '/xdg', () => { - assert.strictEqual(getGlobalConfigDir('kilo'), path.join('/xdg', 'kilo')); - }); - }); - }); - }); - - test('antigravity detects 2.x IDE dir when legacy dir is absent', () => { - const home = require('node:fs').mkdtempSync(path.join(os.tmpdir(), 'gsd-antigravity-home-')); - try { - require('node:fs').mkdirSync(path.join(home, '.gemini', 'antigravity-ide'), { recursive: true }); - const savedHome = process.env.HOME; - const savedUserProfile = process.env.USERPROFILE; - process.env.HOME = home; - process.env.USERPROFILE = home; - withEnv('ANTIGRAVITY_CONFIG_DIR', undefined, () => { - assert.strictEqual( - getGlobalConfigDir('antigravity'), - path.join(home, '.gemini', 'antigravity-ide'), - ); - }); - if (savedHome === undefined) delete process.env.HOME; - else process.env.HOME = savedHome; - if (savedUserProfile === undefined) delete process.env.USERPROFILE; - else process.env.USERPROFILE = savedUserProfile; - } finally { - cleanup(home); - } - }); -}); - -describe('bug #3126: runtime-homes getGlobalSkillsBase', () => { - test('most runtimes: skills at /skills', () => { - withEnv('CURSOR_CONFIG_DIR', undefined, () => { - assert.strictEqual( - getGlobalSkillsBase('cursor'), - path.join(os.homedir(), '.cursor', 'skills'), - ); - }); - }); - test('hermes: skills at /skills/gsd (nested layout #2841)', () => { - withEnv('HERMES_HOME', undefined, () => { - assert.strictEqual( - getGlobalSkillsBase('hermes'), - path.join(os.homedir(), '.hermes', 'skills', 'gsd'), - ); - }); - }); - test('cline: returns ~/.cline/skills (skills-capable since v3.48.0 — #782)', () => { - withEnv('CLINE_CONFIG_DIR', undefined, () => { - assert.strictEqual( - getGlobalSkillsBase('cline'), - path.join(os.homedir(), '.cline', 'skills'), - ); - }); - }); -}); - -describe('bug #3126: runtime-homes getGlobalSkillDir', () => { - test('cursor: /skills/', () => { - withEnv('CURSOR_CONFIG_DIR', undefined, () => { - assert.strictEqual( - getGlobalSkillDir('cursor', 'gsd-executor'), - path.join(os.homedir(), '.cursor', 'skills', 'gsd-executor'), - ); - }); - }); - test('hermes: /skills/gsd/', () => { - withEnv('HERMES_HOME', undefined, () => { - assert.strictEqual( - getGlobalSkillDir('hermes', 'gsd-executor'), - path.join(os.homedir(), '.hermes', 'skills', 'gsd', 'gsd-executor'), - ); - }); - }); - test('cline: returns ~/.cline/skills/gsd-executor (skills-capable since v3.48.0 — #782)', () => { - withEnv('CLINE_CONFIG_DIR', undefined, () => { - assert.strictEqual( - getGlobalSkillDir('cline', 'gsd-executor'), - path.join(os.homedir(), '.cline', 'skills', 'gsd-executor'), - ); - }); - }); -}); - -describe('getGlobalConfigDir — explicitDir override and opencode/kilo file-path precedence', () => { - // ── explicitDir override ────────────────────────────────────────────────── - test('explicitDir absolute path is returned as-is (claude)', () => { - assert.strictEqual(String(getGlobalConfigDir('claude', '/tmp/x')).replace(/\\/g, '/'), '/tmp/x'); - }); - - test('explicitDir with tilde is expanded (opencode)', () => { - assert.strictEqual( - getGlobalConfigDir('opencode', '~/foo'), - path.join(os.homedir(), 'foo'), - ); - }); - - test('explicitDir wins even when OPENCODE_CONFIG_DIR is also set', () => { - withEnv('OPENCODE_CONFIG_DIR', '/should/not/win', () => { - assert.strictEqual(String(getGlobalConfigDir('opencode', '/explicit/wins')).replace(/\\/g, '/'), '/explicit/wins'); - }); - }); - - // ── opencode: OPENCODE_CONFIG file-path step ────────────────────────────── - test('opencode: OPENCODE_CONFIG → path.dirname(expandTilde(value))', () => { - withEnv('OPENCODE_CONFIG_DIR', undefined, () => { - withEnv('XDG_CONFIG_HOME', undefined, () => { - withEnv('OPENCODE_CONFIG', '/home/u/cfg/opencode.json', () => { - assert.strictEqual(String(getGlobalConfigDir('opencode')).replace(/\\/g, '/'), '/home/u/cfg'); - }); - }); - }); - }); - - test('opencode: OPENCODE_CONFIG_DIR takes precedence over OPENCODE_CONFIG', () => { - withEnv('OPENCODE_CONFIG_DIR', '/dir/wins', () => { - withEnv('OPENCODE_CONFIG', '/file/loses.json', () => { - assert.strictEqual(String(getGlobalConfigDir('opencode')).replace(/\\/g, '/'), '/dir/wins'); - }); - }); - }); - - test('opencode: OPENCODE_CONFIG takes precedence over XDG_CONFIG_HOME', () => { - withEnv('OPENCODE_CONFIG_DIR', undefined, () => { - withEnv('OPENCODE_CONFIG', '/cfg/opencode.json', () => { - withEnv('XDG_CONFIG_HOME', '/xdg/should/lose', () => { - assert.strictEqual(String(getGlobalConfigDir('opencode')).replace(/\\/g, '/'), '/cfg'); - }); - }); - }); - }); - - test('opencode: default ~/.config/opencode when no env vars set', () => { - withEnv('OPENCODE_CONFIG_DIR', undefined, () => { - withEnv('OPENCODE_CONFIG', undefined, () => { - withEnv('XDG_CONFIG_HOME', undefined, () => { - assert.strictEqual( - getGlobalConfigDir('opencode'), - path.join(os.homedir(), '.config', 'opencode'), - ); - }); - }); - }); - }); - - // ── kilo: KILO_CONFIG file-path step ───────────────────────────────────── - test('kilo: KILO_CONFIG → path.dirname(expandTilde(value))', () => { - withEnv('KILO_CONFIG_DIR', undefined, () => { - withEnv('XDG_CONFIG_HOME', undefined, () => { - withEnv('KILO_CONFIG', '/home/u/cfg/kilo.json', () => { - assert.strictEqual(String(getGlobalConfigDir('kilo')).replace(/\\/g, '/'), '/home/u/cfg'); - }); - }); - }); - }); - - test('kilo: KILO_CONFIG_DIR takes precedence over KILO_CONFIG', () => { - withEnv('KILO_CONFIG_DIR', '/dir/wins', () => { - withEnv('KILO_CONFIG', '/file/loses.json', () => { - assert.strictEqual(String(getGlobalConfigDir('kilo')).replace(/\\/g, '/'), '/dir/wins'); - }); - }); - }); - - test('kilo: KILO_CONFIG takes precedence over XDG_CONFIG_HOME', () => { - withEnv('KILO_CONFIG_DIR', undefined, () => { - withEnv('KILO_CONFIG', '/cfg/kilo.json', () => { - withEnv('XDG_CONFIG_HOME', '/xdg/should/lose', () => { - assert.strictEqual(String(getGlobalConfigDir('kilo')).replace(/\\/g, '/'), '/cfg'); - }); - }); - }); - }); - - test('kilo: default ~/.config/kilo when no env vars set', () => { - withEnv('KILO_CONFIG_DIR', undefined, () => { - withEnv('KILO_CONFIG', undefined, () => { - withEnv('XDG_CONFIG_HOME', undefined, () => { - assert.strictEqual( - getGlobalConfigDir('kilo'), - path.join(os.homedir(), '.config', 'kilo'), - ); - }); - }); - }); - }); -}); - -describe('bug #3126: init.cjs uses runtime-homes not hardcoded .claude', () => { - test('init.cjs has no hardcoded globalSkillsBase assignment to ~/.claude/skills', () => { - const fs = require('node:fs'); - const src = fs.readFileSync( - path.join(ROOT, 'gsd-core', 'bin', 'lib', 'init.cjs'), - 'utf8', - ); - assert.ok( - !src.includes("const globalSkillsBase = path.join(os.homedir(), '.claude', 'skills')"), - 'init.cjs still assigns globalSkillsBase to hardcoded ~/.claude/skills — fix not applied', - ); - }); - test('init.cjs requires runtime-homes', () => { - const fs = require('node:fs'); - const src = fs.readFileSync( - path.join(ROOT, 'gsd-core', 'bin', 'lib', 'init.cjs'), - 'utf8', - ); - assert.ok( - src.includes('runtime-homes'), - 'init.cjs does not require runtime-homes.cjs', - ); - }); - test('init.cjs warning message no longer hardcodes ~/.claude/skills', () => { - const fs = require('node:fs'); - const src = fs.readFileSync( - path.join(ROOT, 'gsd-core', 'bin', 'lib', 'init.cjs'), - 'utf8', - ); - assert.ok( - !src.includes("~/.claude/skills/${skillName}/SKILL.md"), - 'init.cjs warning message still hardcodes ~/.claude/skills path', - ); - }); -}); diff --git a/tests/bug-3127-state-begin-phase-idempotent.test.cjs b/tests/bug-3127-state-begin-phase-idempotent.test.cjs deleted file mode 100644 index 4ef566832..000000000 --- a/tests/bug-3127-state-begin-phase-idempotent.test.cjs +++ /dev/null @@ -1,179 +0,0 @@ -'use strict'; -// allow-test-rule: reads runtime STATE.md written to temp dir — behavioral output test, not source-grep - -// Regression tests for bug #3127. -// -// state.begin-phase is non-idempotent: when execute-phase calls it on a phase -// that is already mid-flight (e.g. --wave N resume), the handler unconditionally -// overwrites execution-progress fields with stale values from the last plan-phase run: -// - stopped_at / Last Activity Description reset to "context gathered; ready for plan-phase" -// - Current Plan reset to 1 (from plan being executed, e.g. 3) -// - Plan: N of M body line reset to "Plan: 1 of M" -// - Last activity timestamp reverted to an older value -// - progress.percent may decrease -// -// Fix: read the current Status field before writing. If the phase is already -// "Executing Phase N", skip the execution-progress fields (Current Plan, plan body -// line, Last Activity Description) and only update fields safe to overwrite on -// resume (Last Activity date, Status if somehow wrong). -// A --force flag bypasses the guard for intentional full resets. - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const os = require('node:os'); -const path = require('node:path'); -const { cleanup } = require('./helpers.cjs'); - -const ROOT = path.join(__dirname, '..'); - -// Load the state.cjs module internals via the command router -function requireStateCjs() { - return require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'state.cjs')); -} - -function makeTempPlanning(stateContent) { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3127-')); - const planningDir = path.join(dir, '.planning'); - fs.mkdirSync(planningDir, { recursive: true }); - fs.writeFileSync(path.join(planningDir, 'STATE.md'), stateContent, 'utf8'); - return dir; -} - -// A STATE.md that is mid-flight on Phase 5 (Plan 3 of 8 in progress) -const MID_FLIGHT_STATE = `# GSD State - -## Configuration -Current Phase: 5 -Current Phase Name: test-phase -Total Plans in Phase: 8 -Current Plan: 3 -Status: Executing Phase 5 - -## Current Position - -Phase: 5 (test-phase) — EXECUTING -Plan: 3 of 8 (Plan 00 SHIPPED — wave 1 complete; Plan 01 SHIPPED; Plan 02 next) -Status: Executing Phase 5 -Last activity: 2026-05-05 -- Plan 02 SHIPPED wave 2 GREEN - -## Progress - -progress: - total_phases: 10 - completed_phases: 4 - percent: 89 - -stopped_at: Phase 5 Plan 02 SHIPPED — Wave 2 GREEN detailed narrative here; ready for Plan 03 -`; - -// A STATE.md that is NOT yet executing (plan-phase just ran) -const PRE_EXECUTE_STATE = `# GSD State - -## Configuration -Current Phase: 5 -Current Phase Name: test-phase -Total Plans in Phase: 8 -Current Plan: 1 -Status: Ready to execute - -## Current Position - -Phase: 5 (test-phase) — READY -Plan: 1 of 8 -Status: Ready to execute -Last activity: 2026-05-04 -- context gathered; ready for plan-phase - -stopped_at: Phase 5 context gathered; ready for plan-phase -`; - -describe('bug #3127: state.begin-phase idempotency guard', () => { - test('begin-phase on a mid-flight phase does not reset Current Plan', () => { - const stateModule = requireStateCjs(); - const { cmdStateBeginPhase } = stateModule; - if (!cmdStateBeginPhase) { - // Skip if not exported — the guard may be inside a private function - return; - } - const dir = makeTempPlanning(MID_FLIGHT_STATE); - try { - cmdStateBeginPhase(dir, '5', 'test-phase', 8, false); - const after = fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8'); - // Current Plan must not have been reset to 1 - const planMatch = after.match(/^Current Plan:\s*(\S+)/m); - if (planMatch) { - assert.notStrictEqual(planMatch[1], '1', - 'begin-phase reset Current Plan to 1 on a mid-flight phase — idempotency guard not applied'); - } - } finally { - cleanup(dir); - } - }); - - test('begin-phase on a mid-flight phase does not overwrite stopped_at narrative', () => { - const stateModule = requireStateCjs(); - const { cmdStateBeginPhase } = stateModule; - if (!cmdStateBeginPhase) return; - const dir = makeTempPlanning(MID_FLIGHT_STATE); - try { - cmdStateBeginPhase(dir, '5', 'test-phase', 8, false); - const after = fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8'); - // The rich stopped_at narrative must be preserved - assert.ok( - after.includes('Plan 02 SHIPPED') || after.includes('Wave 2 GREEN'), - 'begin-phase overwrote stopped_at narrative on a mid-flight phase', - ); - } finally { - cleanup(dir); - } - }); - - test('begin-phase on a NOT-yet-executing phase sets Current Plan to 1 (normal path)', () => { - const stateModule = requireStateCjs(); - const { cmdStateBeginPhase } = stateModule; - if (!cmdStateBeginPhase) return; - const dir = makeTempPlanning(PRE_EXECUTE_STATE); - try { - cmdStateBeginPhase(dir, '5', 'test-phase', 8, false); - const after = fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8'); - // Normal path: Current Plan should become 1 (or stay 1) - const planMatch = after.match(/^Current Plan:\s*(\S+)/m); - if (planMatch) { - assert.strictEqual(planMatch[1], '1', - 'begin-phase should set Current Plan to 1 on a fresh phase'); - } - } finally { - cleanup(dir); - } - }); - - test('begin-phase always updates Last Activity date (safe on resume, pinned via GSD_NOW_MS)', () => { - const stateModule = requireStateCjs(); - const { cmdStateBeginPhase } = stateModule; - if (!cmdStateBeginPhase) return; - const dir = makeTempPlanning(MID_FLIGHT_STATE); - - const PINNED_MS = Date.parse('2020-11-25T09:00:00.000Z'); - const PINNED_DATE = '2020-11-25'; - // Pin the in-process clock via env vars before calling the function directly. - const origTestMode = process.env.GSD_TEST_MODE; - const origNowMs = process.env.GSD_NOW_MS; - process.env.GSD_TEST_MODE = '1'; - process.env.GSD_NOW_MS = String(PINNED_MS); - try { - cmdStateBeginPhase(dir, '5', 'test-phase', 8, false); - const after = fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8'); - assert.ok( - after.includes(PINNED_DATE), - `begin-phase must update Last Activity date to the pinned date ${PINNED_DATE} even on resume (safe field)`, - ); - } finally { - // Restore env vars before cleanup to avoid leaking state to other tests. - if (origTestMode === undefined) delete process.env.GSD_TEST_MODE; - else process.env.GSD_TEST_MODE = origTestMode; - if (origNowMs === undefined) delete process.env.GSD_NOW_MS; - else process.env.GSD_NOW_MS = origNowMs; - cleanup(dir); - } - }); -}); diff --git a/tests/bug-3128-roadmap-plan-count-slug-layout.test.cjs b/tests/bug-3128-roadmap-plan-count-slug-layout.test.cjs deleted file mode 100644 index 7f3ba934b..000000000 --- a/tests/bug-3128-roadmap-plan-count-slug-layout.test.cjs +++ /dev/null @@ -1,89 +0,0 @@ -'use strict'; -// allow-test-rule: reads roadmap.cjs source to verify isPlanFile pattern was adopted — structural contract prevents silent regression to old filter - -// Regression guard for bug #3128. -// -// roadmap.cjs countPhasePlansAndSummaries() used to filter plan files with: -// f.endsWith('-PLAN.md') || f === 'PLAN.md' -// This misses the {N}-PLAN-{NN}-{slug}.md layout that gsd-plan-phase -// actually writes (e.g. 5-PLAN-01-setup-database.md), ending in -database.md. -// Result: init manager returned plan_count=0 and disk_status='discussed' for -// fully-planned phases, triggering unnecessary background planner agents. -// -// Root cause: same regex flaw as #2893 (fixed in phase.cjs via #2896), but -// the manager-dashboard path in roadmap.cjs was not updated alongside it. -// -// Fix: apply the same looksLikePlanFile logic from phase.cjs to roadmap.cjs. - -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, '..'); -// Require the module under test directly -const roadmapLib = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'roadmap.cjs'); -const planScanLib = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'plan-scan.cjs'); - -// We test countPhasePlansAndSummaries indirectly via getManagerInfo since -// it is not exported. We build a real phaseDir on disk and call the full -// roadmap.cjs init manager path via its exported helper, or fall back to -// direct filesystem inspection of what the filter would produce. -// The simplest correct seam: inspect the source for the regex pattern and -// validate with a synthetic directory that the manager path returns correct counts. - - -// Import countPhasePlansAndSummaries by monkey-patching: we inline the -// fixed filter logic and verify it matches the file on disk. -// Since the function is module-private, we validate via its public caller -// by using the exported analyzeRoadmap / getPhaseInfo path with a -// synthetic .planning/ directory tree. - -describe('bug #3128: roadmap.cjs plan-count for {N}-PLAN-{NN}-{slug}.md layout', () => { - - test('isPlanFile rejects PLAN-OUTLINE and pre-bounce derivatives', () => { - // Inlined from fix — mirrors the exact logic in the fix - const PLAN_OUTLINE_RE = /-PLAN-OUTLINE\.md$/i; - const PLAN_PRE_BOUNCE_RE = /-PLAN.*\.pre-bounce\.md$/i; - const isPlanFile = (f) => - (f.endsWith('-PLAN.md') || f === 'PLAN.md') || - (/\.md$/i.test(f) && /PLAN/i.test(f) && !PLAN_OUTLINE_RE.test(f) && !PLAN_PRE_BOUNCE_RE.test(f)); - - // canonical forms — must match - assert.ok(isPlanFile('PLAN.md'), 'PLAN.md must match'); - assert.ok(isPlanFile('5-PLAN.md'), '5-PLAN.md must match'); - assert.ok(isPlanFile('05-PLAN.md'), '05-PLAN.md must match'); - - // slug form — was the bug; must now match - assert.ok(isPlanFile('5-PLAN-01-setup.md'), '5-PLAN-01-setup.md must match'); - assert.ok(isPlanFile('05-PLAN-02-database.md'), '05-PLAN-02-database.md must match'); - assert.ok(isPlanFile('5-PLAN-DELTA-2026-05-05.md'), '5-PLAN-DELTA-2026-05-05.md must match'); - - // derivative files — must NOT match - assert.ok(!isPlanFile('5-PLAN-OUTLINE.md'), 'PLAN-OUTLINE must not match'); - assert.ok(!isPlanFile('5-PLAN-01.pre-bounce.md'), 'pre-bounce must not match'); - assert.ok(!isPlanFile('CONTEXT.md'), 'CONTEXT.md must not match'); - assert.ok(!isPlanFile('SUMMARY.md'), 'SUMMARY.md must not match'); - assert.ok(!isPlanFile('5-RESEARCH.md'), 'RESEARCH.md must not match'); - }); - - test('roadmap.cjs source uses the extended isPlanFile filter', () => { - const roadmapSrc = fs.readFileSync(roadmapLib, 'utf8'); - // Verify the fix is in place: the old simple inline filter is gone from roadmap.cjs - assert.ok( - !roadmapSrc.includes("phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md')"), - 'Old simple plan filter still present in roadmap.cjs — fix not applied', - ); - // roadmap.cjs now delegates to plan-scan.cjs via require('./plan-scan.cjs') - assert.ok( - roadmapSrc.includes('plan-scan.cjs'), - 'roadmap.cjs does not require plan-scan.cjs — delegation not applied', - ); - // plan-scan.cjs is where the extended plan-file detection logic lives (isRootPlanFile) - const planScanSrc = fs.readFileSync(planScanLib, 'utf8'); - assert.ok( - planScanSrc.includes('isRootPlanFile') && planScanSrc.includes('/PLAN/i'), - 'isRootPlanFile with /PLAN/i not found in plan-scan.cjs — canonical helper missing extended filter', - ); - }); -}); diff --git a/tests/bug-3135-capture-backlog-workflow.test.cjs b/tests/bug-3135-capture-backlog-workflow.test.cjs deleted file mode 100644 index 20c8d2e7c..000000000 --- a/tests/bug-3135-capture-backlog-workflow.test.cjs +++ /dev/null @@ -1,130 +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 #3135. -// -// PR #2824 consolidated add-backlog into `gsd-capture --backlog` by creating -// a routing wrapper in commands/gsd/capture.md that delegates to -// workflows/add-backlog.md via execution_context. The workflow file was never -// created. Same gap class as reapply-patches.md (found and fixed in the same PR). -// -// Fix: create gsd-core/workflows/add-backlog.md with the full process -// ported from the deleted commands/gsd/add-backlog.md (git ref 87917131^). - -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 WORKFLOW = path.join(ROOT, 'gsd-core', 'workflows', 'add-backlog.md'); -const COMMANDS_DIR = path.join(ROOT, 'commands', 'gsd'); - -// ─── #3135: add-backlog workflow ───────────────────────────────────────────── - -describe('#3135: gsd-core/workflows/add-backlog.md', () => { - test('file exists', () => { - assert.ok( - fs.existsSync(WORKFLOW), - 'gsd-core/workflows/add-backlog.md does not exist — capture --backlog has no implementation to load', - ); - }); - - test('uses gsd-sdk query phase.next-decimal to find next 999.x slot', () => { - const src = fs.readFileSync(WORKFLOW, 'utf8'); - assert.ok( - src.includes('phase.next-decimal'), - 'add-backlog.md must use gsd-sdk query phase.next-decimal to find the next 999.x number', - ); - }); - - test('writes to ROADMAP.md', () => { - const src = fs.readFileSync(WORKFLOW, 'utf8'); - assert.ok(src.includes('ROADMAP.md'), 'add-backlog.md must write to ROADMAP.md'); - }); - - test('creates a .planning/phases/ directory', () => { - const src = fs.readFileSync(WORKFLOW, 'utf8'); - assert.ok( - src.includes('.planning/phases') || src.includes('planning/phases'), - 'add-backlog.md must create a phase directory under .planning/phases/', - ); - }); - - test('uses generate-slug for the directory name', () => { - const src = fs.readFileSync(WORKFLOW, 'utf8'); - assert.ok( - src.includes('generate-slug'), - 'add-backlog.md must use gsd-sdk query generate-slug to build the phase directory slug', - ); - }); - - test('commits via gsd-sdk query commit', () => { - const src = fs.readFileSync(WORKFLOW, 'utf8'); - assert.ok( - src.includes('gsd-sdk query commit') || src.includes('query commit'), - 'add-backlog.md must commit via gsd-sdk query commit', - ); - }); - - test('writes ROADMAP entry before creating directory (#2280 ordering invariant)', () => { - const src = fs.readFileSync(WORKFLOW, 'utf8'); - const roadmapIdx = src.indexOf('ROADMAP.md'); - const mkdirIdx = src.search(/mkdir|\.gitkeep/); - assert.ok(roadmapIdx !== -1, 'ROADMAP.md write step not found'); - assert.ok(mkdirIdx !== -1, 'directory creation step not found'); - assert.ok( - roadmapIdx < mkdirIdx, - 'ROADMAP.md entry must be written BEFORE the phase directory is created (#2280 ordering invariant)', - ); - }); - - test('uses 999.x numbering for backlog items', () => { - const src = fs.readFileSync(WORKFLOW, 'utf8'); - assert.ok( - src.includes('999'), - 'add-backlog.md must document 999.x numbering scheme for backlog items', - ); - }); - - test('documents /gsd-review-backlog for promotion', () => { - const src = fs.readFileSync(WORKFLOW, 'utf8'); - assert.ok( - src.includes('review-backlog') || src.includes('gsd-review-backlog'), - 'add-backlog.md should mention /gsd-review-backlog for promoting items to active milestone', - ); - }); -}); - -// ─── capture.md routing integrity ──────────────────────────────────────────── - -describe('#3135: capture.md correctly routes --backlog to add-backlog workflow', () => { - function executionContextIncludes(body) { - const blocks = [ - ...body.matchAll(/([\s\S]*?)<\/execution_context(?:_extended)?>/g), - ].map((m) => m[1]); - const targets = []; - for (const blk of blocks) { - for (const line of blk.split('\n')) { - const t = line.trim(); - if (!t.startsWith('@')) continue; - const rel = t.replace(/^@~?\/?(?:\.claude\/)?(?:gsd-core\/)?/, ''); - targets.push(rel); - } - } - return targets; - } - - test('capture.md execution_context @-includes add-backlog.md', () => { - const body = fs.readFileSync(path.join(COMMANDS_DIR, 'capture.md'), 'utf8'); - const targets = executionContextIncludes(body); - assert.ok( - targets.some((t) => /(^|\/)workflows\/add-backlog\.md$/.test(t)), - `capture.md execution_context must @-include workflows/add-backlog.md; got: ${JSON.stringify(targets)}`, - ); - }); -}); - diff --git a/tests/bug-3156-plan-phase-opencode-dispatch.test.cjs b/tests/bug-3156-plan-phase-opencode-dispatch.test.cjs deleted file mode 100644 index 0b03f4046..000000000 --- a/tests/bug-3156-plan-phase-opencode-dispatch.test.cjs +++ /dev/null @@ -1,122 +0,0 @@ -'use strict'; - -// allow-test-rule: source-text-is-the-product -// commands/gsd/*.md files are the deployed skill surface. Their frontmatter -// IS the runtime contract. Checking frontmatter fields checks deployed behaviour. - -/** - * #3156 — plan-phase auto-dispatches to gsd-planner subagent on OpenCode, - * losing Task tool access. - * - * Root cause: commands/gsd/plan-phase.md had `agent: gsd-planner` in its - * frontmatter. Per OpenCode docs, `agent: ` in a command causes - * auto-dispatch to a subagent context where the Agent (Task spawner) tool is - * unavailable. Orchestrator commands that need to spawn subagents via the - * Agent tool must NOT carry an `agent:` frontmatter directive. - * - * This test parses the YAML frontmatter of every commands/gsd/*.md file and - * asserts: - * 1. No command file has an `agent:` frontmatter directive at all. - * (The directive causes OpenCode to auto-dispatch, breaking any command - * that relies on the Agent tool to spawn subagents.) - * 2. Any command whose allowed-tools includes `Agent` (an orchestrator) must - * not have `agent:` in its frontmatter. - */ - -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 COMMANDS_DIR = path.join(ROOT, 'commands', 'gsd'); - -/** Parse the YAML frontmatter block between the first two `---` delimiters. */ -function parseFrontmatter(content) { - const lines = content.split(/\r?\n/); - if (lines[0].trim() !== '---') return {}; - const end = lines.findIndex((line, idx) => idx > 0 && line.trim() === '---'); - if (end === -1) return {}; - const fm = {}; - let currentKey = null; - for (const line of lines.slice(1, end)) { - const kv = line.match(/^([a-zA-Z0-9_-]+):\s*(.*)/); - if (kv) { - currentKey = kv[1]; - fm[currentKey] = kv[2].trim(); - } else if (currentKey && line.match(/^\s+-\s+/)) { - const val = line.replace(/^\s+-\s+/, '').trim(); - fm[currentKey] = fm[currentKey] ? fm[currentKey] + '\n' + val : val; - } - } - return fm; -} - -/** Return the list of tools from the allowed-tools frontmatter block. */ -function allowedTools(fm) { - const raw = fm['allowed-tools']; - if (!raw) return []; - // Multi-line YAML list: each entry on its own line - if (raw.includes('\n')) { - return raw.split('\n').map(t => t.trim()).filter(Boolean); - } - return raw.split(',').map(t => t.trim()).filter(Boolean); -} - -const commandFiles = fs - .readdirSync(COMMANDS_DIR) - .filter(f => f.endsWith('.md')) - .map(f => ({ - name: f, - full: path.join(COMMANDS_DIR, f), - content: fs.readFileSync(path.join(COMMANDS_DIR, f), 'utf-8'), - })); - -// ─── No command may carry `agent:` ──────────────────────────────────────────── -// -// OpenCode interprets `agent: ` as "auto-dispatch to this subagent", -// which removes the Agent (subagent-spawner) tool from the command's context. -// Any orchestrator command is immediately broken. Commands that need to run in -// the main agent context (i.e., all GSD commands) must omit this directive. - -describe('#3156 — no command file may have an `agent:` frontmatter directive', () => { - for (const { name, content } of commandFiles) { - test(`${name}: no agent: directive in frontmatter`, () => { - const fm = parseFrontmatter(content); - assert.ok( - !Object.prototype.hasOwnProperty.call(fm, 'agent'), - `${name}: has \`agent: ${fm['agent']}\` in frontmatter — ` + - 'this causes OpenCode to auto-dispatch to a subagent context where the ' + - 'Agent tool is unavailable, breaking orchestrator workflows. ' + - 'Remove the `agent:` directive so the command runs in the main agent context.', - ); - }); - } -}); - -// ─── Orchestrator commands must not have `agent:` ──────────────────────────── -// -// Redundant with the above (belt-and-suspenders), but captures the precise -// failure mode from #3156: a command whose allowed-tools includes `Agent` -// relies on spawning subagents. Pairing that with `agent:` is self-defeating. - -describe('#3156 — orchestrator commands (allowed-tools: Agent) must not have agent:', () => { - const orchestrators = commandFiles.filter(({ content }) => { - const fm = parseFrontmatter(content); - const tools = allowedTools(fm); - return tools.includes('Agent'); - }); - - for (const { name, content } of orchestrators) { - test(`${name}: orchestrator must not carry agent: directive`, () => { - const fm = parseFrontmatter(content); - assert.ok( - !Object.prototype.hasOwnProperty.call(fm, 'agent'), - `${name}: allowed-tools includes Agent (orchestrator) but also has ` + - `\`agent: ${fm['agent']}\` — OpenCode will auto-dispatch to a subagent ` + - 'where Agent is unavailable, making the orchestrator unable to spawn ' + - 'researcher/planner/checker subagents. Remove the `agent:` directive.', - ); - }); - } -}); diff --git a/tests/bug-3168-task-to-agent-rename.test.cjs b/tests/bug-3168-task-to-agent-rename.test.cjs deleted file mode 100644 index 644be5ec1..000000000 --- a/tests/bug-3168-task-to-agent-rename.test.cjs +++ /dev/null @@ -1,114 +0,0 @@ -'use strict'; - -// allow-test-rule: source-text-is-the-product -// commands/gsd/*.md, gsd-core/workflows/*.md, and agents/gsd-*.md are -// deployed product files. Checking their text IS checking the runtime contract. - -/** - * #3168 — Incomplete Task→Agent dispatcher rename causes silent inline fallback. - * - * The Claude Code subagent-dispatcher tool is named `Agent`. The `Task*` namespace - * (TaskCreate, TaskList, TaskGet, TaskUpdate, TaskOutput, TaskStop) is the task - * tracker — a distinct tool set. GSD workflows were partially migrated and still - * reference `Task(` and `- Task` in allowed-tools/tools frontmatter in most files. - */ - -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 COMMANDS_DIR = path.join(ROOT, 'commands', 'gsd'); -const WORKFLOWS_DIR = path.join(ROOT, 'gsd-core', 'workflows'); -const AGENTS_DIR = path.join(ROOT, 'agents'); - -// Task tracker names — these must NOT be renamed -const TASK_TRACKER_PATTERN = /\bTask(?:Create|List|Get|Update|Output|Stop)\b/; - -function readMdFiles(dir, prefix) { - return fs.readdirSync(dir) - .filter(f => f.endsWith('.md') && (!prefix || f.startsWith(prefix))) - .map(f => ({ name: f, path: path.join(dir, f), content: fs.readFileSync(path.join(dir, f), 'utf-8') })); -} - -function extractFrontmatterTools(content) { - const fm = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); - if (!fm) return []; - const toolsMatch = fm[1].match(/^allowed-tools:\s*\r?\n((?:[ \t]+-[^\n]*\n?)*)/m) || - fm[1].match(/^tools:\s*(.+)$/m); - if (!toolsMatch) return []; - const toolsBlock = toolsMatch[1]; - if (toolsBlock.includes('\n')) { - return toolsBlock.match(/[-\s]*([A-Za-z_*][A-Za-z0-9_*]*)/g) - .map(t => t.replace(/^[-\s]+/, '').trim()) - .filter(Boolean); - } - return toolsBlock.split(',').map(t => t.trim()).filter(Boolean); -} - -describe('#3168 — commands/gsd: allowed-tools must use Agent not Task', () => { - const commands = readMdFiles(COMMANDS_DIR); - - for (const cmd of commands) { - test(`${cmd.name}: allowed-tools must not list Task without Agent`, () => { - const tools = extractFrontmatterTools(cmd.content); - const hasTask = tools.includes('Task'); - const hasAgent = tools.includes('Agent'); - assert.ok( - !hasTask || hasAgent, - `${cmd.name}: allowed-tools lists "Task" but not "Agent" — dispatcher tool is "Agent", not "Task"\n tools: [${tools.join(', ')}]`, - ); - assert.ok( - !hasTask, - `${cmd.name}: allowed-tools still lists "Task" — remove it (Agent is the dispatcher tool)\n tools: [${tools.join(', ')}]`, - ); - }); - } -}); - -describe('#3168 — workflows: prose must use Agent( not Task( for dispatcher calls', () => { - const workflows = []; - function collectMd(dir) { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - if (entry.isDirectory()) collectMd(path.join(dir, entry.name)); - else if (entry.name.endsWith('.md')) workflows.push({ name: entry.name, path: path.join(dir, entry.name), content: fs.readFileSync(path.join(dir, entry.name), 'utf-8') }); - } - } - collectMd(WORKFLOWS_DIR); - - for (const wf of workflows) { - test(`${wf.name}: must not contain dispatcher Task( calls`, () => { - const lines = wf.content.split(/\r?\n/); - const violations = []; - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - // Skip code fences that show old examples - if (line.trim().startsWith('```') || line.trim().startsWith('#')) continue; - // Match Task( that is NOT a tracker call (TaskCreate, TaskList, etc.) - if (/\bTask\(/.test(line) && !TASK_TRACKER_PATTERN.test(line)) { - violations.push(` line ${i + 1}: ${line.trim()}`); - } - } - assert.deepStrictEqual( - violations, - [], - `${wf.name}: found dispatcher Task( calls that should be Agent(:\n${violations.join('\n')}`, - ); - }); - } -}); - -describe('#3168 — agents: tools frontmatter must use Agent not Task', () => { - const agents = readMdFiles(AGENTS_DIR, 'gsd-'); - - for (const agent of agents) { - test(`${agent.name}: tools must not list Task`, () => { - const tools = extractFrontmatterTools(agent.content); - assert.ok( - !tools.includes('Task'), - `${agent.name}: tools frontmatter lists "Task" — should be "Agent"\n tools: [${tools.join(', ')}]`, - ); - }); - } -}); diff --git a/tests/bug-321-config-defaults-clone-strategy.test.cjs b/tests/bug-321-config-defaults-clone-strategy.test.cjs deleted file mode 100644 index 4b491bcee..000000000 --- a/tests/bug-321-config-defaults-clone-strategy.test.cjs +++ /dev/null @@ -1,23 +0,0 @@ -'use strict'; - -const { test } = require('node:test'); -const assert = require('node:assert/strict'); -const configuration = require('../gsd-core/bin/lib/configuration.cjs'); - -test('mergeDefaults clones defaults without JSON serialization fragility (#321)', () => { - const sentinelKey = '__bug321_bigint_sentinel__'; - const sentinelValue = BigInt('9007199254740993001'); - - configuration.CONFIG_DEFAULTS[sentinelKey] = sentinelValue; - try { - const merged = configuration.mergeDefaults({}); - assert.equal( - merged[sentinelKey], - sentinelValue, - 'mergeDefaults must preserve non-JSON scalar defaults when cloning' - ); - } - finally { - delete configuration.CONFIG_DEFAULTS[sentinelKey]; - } -}); diff --git a/tests/bug-3212-execute-phase-stall-safe-resume.test.cjs b/tests/bug-3212-execute-phase-stall-safe-resume.test.cjs deleted file mode 100644 index 2776a6921..000000000 --- a/tests/bug-3212-execute-phase-stall-safe-resume.test.cjs +++ /dev/null @@ -1,103 +0,0 @@ -'use strict'; - -// allow-test-rule: source-text-is-product [#3212] -// The bug is in workflow/config contracts consumed by agents at runtime. - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const { spawnSync } = require('node:child_process'); -const fs = require('fs'); -const os = require('os'); -const path = require('path'); -const { cleanup } = require('./helpers.cjs'); - -const ROOT = path.join(__dirname, '..'); - -function read(relativePath) { - return fs.readFileSync(path.join(ROOT, relativePath), 'utf8'); -} - -function runGsd(args, cwd) { - return spawnSync(process.execPath, [path.join(ROOT, 'gsd-core/bin/gsd-tools.cjs'), ...args], { - cwd, - encoding: 'utf8', - }); -} - -describe('bug #3212 execute-phase stall detection and safe resume', () => { - test('config schemas register executor stall detector keys', () => { - // After Cycle 5 (#3536), both CJS and SDK source from the manifest. - // Use the CJS runtime Set for CJS; use the manifest directly for SDK-side - // verification (since config-schema.ts no longer has inline literals). - const { VALID_CONFIG_KEYS: cjsKeys } = require('../gsd-core/bin/lib/config-schema.cjs'); - const manifest = JSON.parse(read('gsd-core/bin/shared/config-schema.manifest.json')); - const manifestKeys = new Set(manifest.validKeys); - - for (const key of ['executor.stall_detect_interval_minutes', 'executor.stall_threshold_minutes']) { - assert.ok(cjsKeys.has(key), `CJS VALID_CONFIG_KEYS must include ${key}`); - assert.ok(manifestKeys.has(key), `Manifest validKeys must include ${key} (SDK sources from manifest)`); - } - }); - - test('configuration docs describe stall detector defaults', () => { - const docs = read('docs/CONFIGURATION.md'); - - assert.match(docs, /`executor\.stall_detect_interval_minutes`\s*\|\s*number\s*\|\s*`5`/); - assert.match(docs, /`executor\.stall_threshold_minutes`\s*\|\s*number\s*\|\s*`10`/); - }); - - test('config-get returns schema defaults for executor stall detector keys', (t) => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3212-')); - t.after(() => cleanup(tmp)); - fs.mkdirSync(path.join(tmp, '.planning')); - fs.writeFileSync(path.join(tmp, '.planning/config.json'), '{}\n'); - - const interval = runGsd(['config-get', 'executor.stall_detect_interval_minutes', '--raw'], tmp); - const threshold = runGsd(['config-get', 'executor.stall_threshold_minutes', '--raw'], tmp); - - assert.equal(interval.status, 0, interval.stderr); - assert.equal(interval.stdout.trim(), '5'); - assert.equal(threshold.status, 0, threshold.stderr); - assert.equal(threshold.stdout.trim(), '10'); - }); - - test('execute-phase verifies partial-plan drift before dispatch', () => { - const workflow = read('gsd-core/workflows/execute-phase.md'); - - assert.match(workflow, / { - const workflow = read('gsd-core/workflows/execute-phase.md'); - - assert.match(workflow, /EXECUTOR_STALL_INTERVAL_MINUTES=.*executor\.stall_detect_interval_minutes/); - assert.match(workflow, /EXECUTOR_STALL_THRESHOLD_MINUTES=.*executor\.stall_threshold_minutes/); - assert.match(workflow, /DISPATCH_TS=/, 'execute-phase must record dispatch timestamp'); - assert.match(workflow, /EXPECTED_BRANCH=/, 'execute-phase must record expected branch'); - assert.match(workflow, /git log "\$\{EXPECTED_BRANCH\}" --since="\$\{DISPATCH_TS\}"/, 'stall check must inspect branch commits since dispatch'); - assert.match(workflow, /continue waiting/, 'stall warning must offer continue waiting'); - assert.match(workflow, /kill and retry/, 'stall warning must offer kill and retry'); - assert.match(workflow, /kill and switch to inline execution/, 'stall warning must offer inline fallback'); - }); - - test('execute-plan documents atomic close-out invariant', () => { - const workflow = read('gsd-core/workflows/execute-plan.md'); - - assert.match(workflow, //, 'execute-plan must contain a formal atomic close-out invariant'); - assert.match(workflow, /production-code commit\(s\) -> SUMMARY commit -> STATE\/ROADMAP update/, 'invariant must name the legal close-out sequence'); - assert.match(workflow, /only legal half-state is mid-production-commits/, 'invariant must define the only legal half-state'); - }); - - test('forensics includes the partial-plan drift detector', () => { - const workflow = read('gsd-core/workflows/forensics.md'); - - assert.match(workflow, /Partial-plan Drift Detection/); - assert.match(workflow, /commits exist but SUMMARY.md is missing/); - assert.match(workflow, /safe-resume verifier/); - }); -}); diff --git a/tests/bug-3227-config-set-model-overrides.test.cjs b/tests/bug-3227-config-set-model-overrides.test.cjs deleted file mode 100644 index 9a2cf5838..000000000 --- a/tests/bug-3227-config-set-model-overrides.test.cjs +++ /dev/null @@ -1,119 +0,0 @@ -'use strict'; - -/** - * Regression test for bug #3227 — config-set rejects model_overrides.. - * - * `gsd-sdk query config-set model_overrides.gsd-plan-checker opus` was - * rejected with "Unknown config key" because `model_overrides.` was - * missing from DYNAMIC_KEY_PATTERNS in both the CJS schema and the SDK schema. - * - * The override mechanism itself worked correctly (resolve-model returned the - * override after a direct file edit). Only the write path was gated wrong. - */ - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs'); -const { DYNAMIC_KEY_PATTERNS, isValidConfigKey } = require('../gsd-core/bin/lib/config-schema.cjs'); - -describe('#3227 — config-set accepts model_overrides.', () => { - test('isValidConfigKey accepts model_overrides.gsd-plan-checker', () => { - assert.ok( - isValidConfigKey('model_overrides.gsd-plan-checker'), - 'model_overrides.gsd-plan-checker must be accepted by isValidConfigKey' - ); - }); - - test('isValidConfigKey accepts model_overrides with various agent-id formats', () => { - const validKeys = [ - 'model_overrides.gsd-executor', - 'model_overrides.gsd-planner', - 'model_overrides.gsd-codebase-mapper', - 'model_overrides.my_custom_agent', - 'model_overrides.agent123', - ]; - for (const key of validKeys) { - assert.ok(isValidConfigKey(key), `isValidConfigKey must accept ${key}`); - } - }); - - test('isValidConfigKey rejects bare model_overrides (no agent-id)', () => { - assert.ok( - !isValidConfigKey('model_overrides'), - 'bare model_overrides must be rejected (use model_overrides.)' - ); - }); - - test('DYNAMIC_KEY_PATTERNS includes an entry for model_overrides', () => { - const hasPattern = DYNAMIC_KEY_PATTERNS.some( - (p) => p.description && p.description.includes('model_overrides') - ); - assert.ok(hasPattern, 'DYNAMIC_KEY_PATTERNS must have an entry covering model_overrides.'); - }); - - test('config-set model_overrides.gsd-plan-checker opus succeeds via gsd-tools.cjs', (t) => { - const tmpDir = createTempProject(); - t.after(() => cleanup(tmpDir)); - - const result = runGsdTools( - ['config-set', 'model_overrides.gsd-plan-checker', 'opus'], - tmpDir - ); - assert.ok( - result.success, - [ - 'config-set model_overrides.gsd-plan-checker opus should succeed,', - 'got:', - 'stdout: ' + result.output, - 'stderr: ' + result.error, - ].join('\n') - ); - }); - - test('config-set model_overrides.gsd-plan-checker opus writes to config.json', (t) => { - const tmpDir = createTempProject(); - t.after(() => cleanup(tmpDir)); - - runGsdTools(['config-set', 'model_overrides.gsd-plan-checker', 'opus'], tmpDir); - - const configPath = path.join(tmpDir, '.planning', 'config.json'); - assert.ok(fs.existsSync(configPath), '.planning/config.json must exist after config-set'); - - const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); - assert.ok( - config.model_overrides !== undefined && - config.model_overrides['gsd-plan-checker'] === 'opus', - [ - 'Expected model_overrides["gsd-plan-checker"]: "opus" in config.json,', - 'got: ' + JSON.stringify(config), - ].join('\n') - ); - }); - - test('config-get model_overrides.gsd-plan-checker returns opus after config-set', (t) => { - const tmpDir = createTempProject(); - t.after(() => cleanup(tmpDir)); - - runGsdTools(['config-set', 'model_overrides.gsd-plan-checker', 'opus'], tmpDir); - - const getResult = runGsdTools( - ['config-get', 'model_overrides.gsd-plan-checker'], - tmpDir - ); - assert.ok( - getResult.success, - [ - 'config-get model_overrides.gsd-plan-checker should succeed,', - 'got:', - 'stdout: ' + getResult.output, - 'stderr: ' + getResult.error, - ].join('\n') - ); - assert.ok( - getResult.output.includes('opus'), - 'config-get output should contain "opus", got: ' + getResult.output - ); - }); -}); diff --git a/tests/bug-3242-state-update-progress-trample.test.cjs b/tests/bug-3242-state-update-progress-trample.test.cjs deleted file mode 100644 index d1fc5bff8..000000000 --- a/tests/bug-3242-state-update-progress-trample.test.cjs +++ /dev/null @@ -1,470 +0,0 @@ -'use strict'; -// Regression tests for issue #3242 — two distinct bugs in state.cjs: -// -// Bug A: cmdStateUpdate("Last Activity", date) triggers a full disk-derived -// progress.* block rebuild via readModifyWriteStateMd → syncStateFrontmatter → -// buildStateFrontmatter, which tramples manually-curated cross-milestone counters -// in STATE.md frontmatter. A body-only field update must not modify progress.*. -// -// Bug B: buildStateFrontmatter (and the duplicate in cmdStateSync) derives -// progress.percent = completedPlans / totalPlans. When ROADMAP declares more -// phases than have dirs on disk, all plans being summarised gives percent: 100 -// even though half the phases are unrealised. The formula must be -// min(plan_fraction, phase_fraction) to reflect true completion. - -const { describe, test, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); -const { extractFrontmatter } = require('../gsd-core/bin/lib/frontmatter.cjs'); - -// ───────────────────────────────────────────────────────────────────────────── -// Helpers -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Build a minimal STATE.md body with frontmatter that has curated progress.*. - * The progress values are cross-milestone aggregates that must NOT be overwritten - * by a body-only field update. - */ -function buildStateWithCuratedProgress(opts) { - const { - completedPlans = 22, - totalPlans = 22, - completedPhases = 6, - totalPhases = 12, - percent = 50, - lastActivity = '2026-01-01', - } = opts || {}; - - return [ - '---', - 'gsd_state_version: 1.0', - 'status: executing', - 'progress:', - ` total_phases: ${totalPhases}`, - ` completed_phases: ${completedPhases}`, - ` total_plans: ${totalPlans}`, - ` completed_plans: ${completedPlans}`, - ` percent: ${percent}`, - '---', - '', - '# GSD State', - '', - '## Configuration', - 'Current Phase: 6', - 'Current Phase Name: test-phase', - 'Total Plans in Phase: 4', - 'Current Plan: 1', - 'Status: Executing Phase 6', - `Last Activity: ${lastActivity}`, - '', - ].join('\n'); -} - -/** - * Write a ROADMAP.md with `numPhases` phase headings (matching `## Phase N:` pattern). - * Only `numRealizedDirs` phase dirs will have plan/summary files on disk. - */ -function buildRoadmap(numPhases) { - const lines = ['# ROADMAP', '', '## Milestone v1.0', '']; - for (let i = 1; i <= numPhases; i++) { - lines.push(`### Phase ${i}: phase-${i}`); - lines.push(''); - } - return lines.join('\n'); -} - -/** - * Create phase dirs with full plan+summary coverage for the first `count` phases. - * Each dir gets 1 PLAN + 1 SUMMARY so the disk-scan treats them as complete. - */ -function createPhaseDirs(phasesDir, count) { - for (let i = 1; i <= count; i++) { - const dir = path.join(phasesDir, String(i).padStart(2, '0')); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(path.join(dir, `01-PLAN.md`), `# Plan\n`); - fs.writeFileSync(path.join(dir, `01-SUMMARY.md`), `# Summary\n`); - } -} - -function createPhasePlanOnlyDirs(phasesDir, count) { - for (let i = 1; i <= count; i++) { - const dir = path.join(phasesDir, String(i).padStart(2, '0')); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(path.join(dir, `01-PLAN.md`), `# Plan\n`); - } -} - -function readPersistedProgress(statePath) { - const fm = extractFrontmatter(fs.readFileSync(statePath, 'utf-8')); - assert.ok(fm.progress, 'persisted frontmatter must have a progress block'); - return Object.fromEntries( - Object.entries(fm.progress).map(([key, value]) => [key, Number(value)]), - ); -} - -function assertProgressEquals(actual, expected) { - for (const [key, value] of Object.entries(expected)) { - assert.strictEqual( - actual[key], - value, - `persisted progress.${key} expected ${value}, got ${actual[key]}`, - ); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Bug A: state.update must not trample curated progress.* frontmatter -// ───────────────────────────────────────────────────────────────────────────── - -describe('#3242 Bug A: body-only state.update preserves curated progress frontmatter', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempProject(); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('state.update "Last Activity" does not overwrite progress.completed_plans', (_t) => { - const statePath = path.join(tmpDir, '.planning', 'STATE.md'); - fs.writeFileSync(statePath, buildStateWithCuratedProgress({ - completedPlans: 22, - totalPlans: 22, - completedPhases: 6, - totalPhases: 12, - percent: 50, - lastActivity: '2026-01-01', - })); - - // Write 6 phase dirs with full coverage — disk would report 6/6 phases done, - // 6/6 plans done (percent=100 from plans-only formula), but frontmatter says 50%. - const phasesDir = path.join(tmpDir, '.planning', 'phases'); - createPhaseDirs(phasesDir, 6); - - const updateResult = runGsdTools( - ['state', 'update', 'Last Activity', '2026-05-07'], - tmpDir, - ); - assert.ok(updateResult.success, `state update failed: ${updateResult.error}`); - - // Read back and assert via state json (JSON return value, not raw file grep) - const jsonResult = runGsdTools('state json', tmpDir); - assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`); - - const fm = JSON.parse(jsonResult.output); - assert.ok(fm.progress, 'frontmatter must have a progress block'); - - // completed_plans must NOT have been trampled to 6 (disk reality) from the - // curated 22 that was stored in the frontmatter before the update. - assert.strictEqual( - fm.progress.completed_plans, - 22, - `state.update "Last Activity" must not overwrite curated progress.completed_plans ` + - `(was 22, got ${fm.progress.completed_plans})`, - ); - - // total_phases must NOT have been trampled to 6 (disk dirs) from curated 12. - assert.strictEqual( - fm.progress.total_phases, - 12, - `state.update "Last Activity" must not overwrite curated progress.total_phases ` + - `(was 12, got ${fm.progress.total_phases})`, - ); - - // percent must NOT have been trampled to 100 (plan-only formula on 6 realized dirs). - assert.strictEqual( - fm.progress.percent, - 50, - `state.update "Last Activity" must not overwrite curated progress.percent ` + - `(was 50, got ${fm.progress.percent})`, - ); - }); - - test('state.update "Last Activity" updates the body field itself', () => { - const statePath = path.join(tmpDir, '.planning', 'STATE.md'); - fs.writeFileSync(statePath, buildStateWithCuratedProgress({ lastActivity: '2026-01-01' })); - - const updateResult = runGsdTools( - ['state', 'update', 'Last Activity', '2026-05-07'], - tmpDir, - ); - assert.ok(updateResult.success, `state update failed: ${updateResult.error}`); - - // Assert via structured JSON output — not raw file text scanning. - // state json extracts Last Activity from the body and surfaces it as - // fm.last_activity, matching the no-source-grep testing standard. - const jsonResult = runGsdTools('state json', tmpDir); - assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`); - const fm = JSON.parse(jsonResult.output); - assert.strictEqual( - fm.last_activity, - '2026-05-07', - 'state.update should have written the new date to the Last Activity body field', - ); - }); - - test('state.update "Progress" resyncs progress frontmatter from the updated body', () => { - const statePath = path.join(tmpDir, '.planning', 'STATE.md'); - fs.writeFileSync(statePath, buildStateWithCuratedProgress({ - completedPlans: 22, - totalPlans: 22, - completedPhases: 6, - totalPhases: 12, - percent: 50, - }).replace('Last Activity: 2026-01-01\n', 'Last Activity: 2026-01-01\nProgress: [█████░░░░░] 50%\n')); - - const updateResult = runGsdTools( - ['state', 'update', 'Progress', '[████████░░] 80%'], - tmpDir, - ); - assert.ok(updateResult.success, `state update failed: ${updateResult.error}`); - - const jsonResult = runGsdTools('state json', tmpDir); - assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`); - const fm = JSON.parse(jsonResult.output); - assert.strictEqual(fm.progress.percent, 80); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// #1264: state.patch must apply the same progress preservation policy -// ───────────────────────────────────────────────────────────────────────────── - -describe('#1264: state.patch preserves curated progress frontmatter for non-progress fields', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempProject(); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('query state.patch of Current Phase preserves persisted progress.* values', () => { - const statePath = path.join(tmpDir, '.planning', 'STATE.md'); - const curatedProgress = { - total_phases: 4, - completed_phases: 3, - total_plans: 11, - completed_plans: 11, - percent: 75, - }; - fs.writeFileSync(statePath, buildStateWithCuratedProgress({ - completedPlans: curatedProgress.completed_plans, - totalPlans: curatedProgress.total_plans, - completedPhases: curatedProgress.completed_phases, - totalPhases: curatedProgress.total_phases, - percent: curatedProgress.percent, - })); - - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - buildRoadmap(5), - ); - createPhasePlanOnlyDirs(path.join(tmpDir, '.planning', 'phases'), 5); - - const patchResult = runGsdTools([ - 'query', - 'state.patch', - JSON.stringify({ 'Current Phase': '08.2' }), - ], tmpDir); - assert.ok(patchResult.success, `state patch failed: ${patchResult.error}`); - - const output = JSON.parse(patchResult.output); - assert.deepEqual(output.updated, ['Current Phase']); - - const progress = readPersistedProgress(statePath); - assertProgressEquals(progress, curatedProgress); - }); - - test('query state.patch of Total Plans in Phase still resyncs persisted progress.* from the updated body', () => { - const statePath = path.join(tmpDir, '.planning', 'STATE.md'); - fs.writeFileSync(statePath, buildStateWithCuratedProgress({ - completedPlans: 22, - totalPlans: 22, - completedPhases: 6, - totalPhases: 12, - percent: 50, - })); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - buildRoadmap(8), - ); - createPhasePlanOnlyDirs(path.join(tmpDir, '.planning', 'phases'), 8); - - const patchResult = runGsdTools([ - 'query', - 'state.patch', - JSON.stringify({ 'Total Plans in Phase': '8' }), - ], tmpDir); - assert.ok(patchResult.success, `state patch failed: ${patchResult.error}`); - - const output = JSON.parse(patchResult.output); - assert.deepEqual(output.updated, ['Total Plans in Phase']); - - const progress = readPersistedProgress(statePath); - assert.strictEqual(progress.total_plans, 8); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Bug B: progress.percent must use min(plan_fraction, phase_fraction) -// ───────────────────────────────────────────────────────────────────────────── - -describe('#3242 Bug B: progress.percent reflects phase fraction when ROADMAP declares future phases', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempProject(); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('12 declared phases / 6 realized / 6/6 plans done → percent is 50, not 100', (_t) => { - const statePath = path.join(tmpDir, '.planning', 'STATE.md'); - - // Body: 6 realized phases visible to disk scan. - // Frontmatter: intentionally absent so buildStateFrontmatter runs fresh. - fs.writeFileSync(statePath, [ - '# GSD State', - '', - '## Configuration', - 'Current Phase: 6', - 'Current Phase Name: test-phase-6', - 'Total Plans in Phase: 1', - 'Current Plan: 1', - 'Status: Executing Phase 6', - 'Last Activity: 2026-01-01', - '', - ].join('\n')); - - // ROADMAP with 12 phase headings — only 6 will have dirs on disk - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - buildRoadmap(12), - ); - - // 6 fully-realized phases (all plans have summaries) - const phasesDir = path.join(tmpDir, '.planning', 'phases'); - createPhaseDirs(phasesDir, 6); - - // state json rebuilds frontmatter from disk+body — this exercises buildStateFrontmatter - const jsonResult = runGsdTools('state json', tmpDir); - assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`); - - const fm = JSON.parse(jsonResult.output); - assert.ok(fm.progress, 'frontmatter must have a progress block'); - - // ROADMAP declares 12 phases; only 6 exist on disk → totalPhases = 12 - assert.strictEqual( - fm.progress.total_phases, - 12, - `total_phases must reflect ROADMAP-declared count (12), got ${fm.progress.total_phases}`, - ); - - // 6 of 12 phases realized → phase_fraction = 50% - // 6/6 plans done → plan_fraction = 100% - // percent = min(100, 50) = 50 - assert.strictEqual( - fm.progress.percent, - 50, - `percent must be 50 (phase fraction), not 100 (plan fraction) — ` + - `6 of 12 ROADMAP phases realized. Got ${fm.progress.percent}`, - ); - }); - - test('all phases realized: percent equals plan fraction (no artificial cap)', (_t) => { - const statePath = path.join(tmpDir, '.planning', 'STATE.md'); - - fs.writeFileSync(statePath, [ - '# GSD State', - '', - '## Configuration', - 'Current Phase: 3', - 'Current Phase Name: final-phase', - 'Total Plans in Phase: 1', - 'Current Plan: 1', - 'Status: Executing Phase 3', - 'Last Activity: 2026-01-01', - '', - ].join('\n')); - - // ROADMAP declares 3 phases; all 3 have dirs and full plan+summary coverage - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - buildRoadmap(3), - ); - - const phasesDir = path.join(tmpDir, '.planning', 'phases'); - createPhaseDirs(phasesDir, 3); - - const jsonResult = runGsdTools('state json', tmpDir); - assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`); - - const fm = JSON.parse(jsonResult.output); - assert.ok(fm.progress, 'frontmatter must have progress block'); - - // 3/3 phases done → phase_fraction = 100% - // 3/3 plans done → plan_fraction = 100% - // percent = min(100, 100) = 100 - assert.strictEqual( - fm.progress.percent, - 100, - `percent must be 100 when all phases are realized and all plans summarized`, - ); - }); - - test('state sync also reflects phase-fraction-capped percent in body Progress field', () => { - // state sync updates the body's Progress: field — it must use the same capped formula - const statePath = path.join(tmpDir, '.planning', 'STATE.md'); - - fs.writeFileSync(statePath, [ - '# GSD State', - '', - '## Configuration', - 'Current Phase: 6', - 'Current Phase Name: phase-6', - 'Total Plans in Phase: 1', - 'Current Plan: 1', - 'Status: Executing Phase 6', - 'Last Activity: 2026-01-01', - 'Progress: [░░░░░░░░░░] 0%', - '', - ].join('\n')); - - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - buildRoadmap(12), - ); - - const phasesDir = path.join(tmpDir, '.planning', 'phases'); - createPhaseDirs(phasesDir, 6); - - const syncResult = runGsdTools('state sync', tmpDir); - assert.ok(syncResult.success, `state sync failed: ${syncResult.error}`); - - // Read the body's Progress field via state json (JSON output is authoritative) - const jsonResult = runGsdTools('state json', tmpDir); - assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`); - - const fm = JSON.parse(jsonResult.output); - assert.ok(fm.progress, 'frontmatter must have progress block'); - - // state sync wrote a Progress: body field; state json re-derives percent from disk. - // Both must agree: 50%, not 100%. - assert.strictEqual( - fm.progress.percent, - 50, - `state sync must cap percent at phase fraction (50%), got ${fm.progress.percent}`, - ); - }); -}); diff --git a/tests/bug-3258-no-stale-gsd-intel-references.test.cjs b/tests/bug-3258-no-stale-gsd-intel-references.test.cjs deleted file mode 100644 index df2a56632..000000000 --- a/tests/bug-3258-no-stale-gsd-intel-references.test.cjs +++ /dev/null @@ -1,109 +0,0 @@ -// allow-test-rule: source-text-is-the-product — workflow, reference, and docs .md files -// ARE what the runtime loads and what users read; asserting their text content -// tests the deployed skill surface contract, not implementation internals. - -'use strict'; - -// Regression tests for bug #3258. -// -// PR #2790 folded `/gsd-intel` into `/gsd-map-codebase --query`. After that -// consolidation, five prose occurrences in two source files continued to -// reference the retired `/gsd-intel` slash command. Users invoking the wizard -// were directed to a command that no longer exists. -// -// Fix: replace each `/gsd-intel` (the retired user-facing slash command) with -// `/gsd-map-codebase --query` in: -// - gsd-core/references/planning-config.md -// - gsd-core/workflows/settings.md -// - docs/INVENTORY.md -// - docs/USER-GUIDE.md -// - docs/FEATURES.md -// -// Allowed: `gsd-intel-updater` (still-valid agent name, no leading slash), -// `intel.cjs` / `intel.enabled` / `intel.*` (internal backend, not user command), -// CHANGELOG.md (historical record), test files themselves. -// -// This test distinguishes `/gsd-intel` (the retired slash command, leading slash) -// from `gsd-intel-updater` (still-valid agent) by grepping for the literal -// string `/gsd-intel` and then asserting no match survives after excluding -// the `-updater` suffix. - -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, '..'); - -/** Walk a directory recursively and return absolute paths of all .md files. */ -function walkMd(dir) { - const results = []; - if (!fs.existsSync(dir)) return results; - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const abs = path.join(dir, entry.name); - if (entry.isDirectory()) { - results.push(...walkMd(abs)); - } else if (entry.isFile() && entry.name.endsWith('.md')) { - results.push(abs); - } - } - return results; -} - -/** - * Return all lines in `src` that contain `/gsd-intel` (the retired slash - * command) but are NOT the agent name `gsd-intel-updater`. - * We match the literal substring `/gsd-intel` (with leading slash) and then - * exclude any line where the match is immediately followed by `-updater`. - */ -function staleLinesIn(src) { - return src.split('\n').filter((line) => { - if (!line.includes('/gsd-intel')) return false; - // Remove all occurrences of the valid agent name; if nothing remains, skip. - const stripped = line.replace(/\/gsd-intel-updater/g, ''); - return stripped.includes('/gsd-intel'); - }); -} - -const SOURCE_DIRS = [ - path.join(ROOT, 'commands', 'gsd'), - path.join(ROOT, 'gsd-core', 'workflows'), - path.join(ROOT, 'gsd-core', 'references'), - path.join(ROOT, 'agents'), - path.join(ROOT, 'docs'), -]; - -describe('#3258: no stale /gsd-intel slash-command references in product source dirs', () => { - for (const dir of SOURCE_DIRS) { - const files = walkMd(dir); - for (const file of files) { - const rel = path.relative(ROOT, file); - - // Allowed exclusions: - // - CHANGELOG.md is a historical record; /gsd-intel appears in release notes - // - test files (under tests/) are excluded automatically by SOURCE_DIRS scope - if (rel === 'CHANGELOG.md') continue; - - test(`${rel} has no stale /gsd-intel references`, () => { - let src; - try { - src = fs.readFileSync(file, 'utf8'); - } catch (err) { - throw new Error(`failed reading ${rel}: ${err.message}`); - } - - const staleLines = staleLinesIn(src); - assert.strictEqual( - staleLines.length, - 0, - [ - `${rel} contains ${staleLines.length} stale /gsd-intel reference(s).`, - 'Replace with /gsd-map-codebase --query (retired by PR #2790).', - 'Stale lines:', - ...staleLines.map((l) => ` ${l.trim()}`), - ].join('\n'), - ); - }); - } - } -}); diff --git a/tests/bug-3413-shell-command-projection.test.cjs b/tests/bug-3413-shell-command-projection.test.cjs deleted file mode 100644 index 8a793fb01..000000000 --- a/tests/bug-3413-shell-command-projection.test.cjs +++ /dev/null @@ -1,278 +0,0 @@ -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const path = require('node:path'); - -const projection = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'shell-command-projection.cjs')); -const install = require(path.join(__dirname, '..', 'bin', 'install.js')); - -const { - hookCommandNeedsPowerShellCallOperator, - formatHookCommandForRuntime, - isManagedHookBasename, - isManagedHookCommand, - projectLocalHookPrefix, - projectLegacySettingsHookCommand, - projectPortableHookBaseDir, -} = projection; -const { buildHookCommand, rewriteLegacyManagedNodeHookCommands } = install; - -describe('bug #3413: Shell Command Projection Module uses runtime-aware hook policy', () => { - test('Gemini on Windows requires PowerShell call operator', () => { - assert.equal( - hookCommandNeedsPowerShellCallOperator({ platform: 'win32', runtime: 'gemini' }), - true, - ); - assert.equal( - formatHookCommandForRuntime('"C:/node.exe" "C:/hook.js"', { platform: 'win32', runtime: 'gemini' }), - '& "C:/node.exe" "C:/hook.js"', - ); - }); - - test('Claude on Windows stays shell-neutral', () => { - assert.equal( - hookCommandNeedsPowerShellCallOperator({ platform: 'win32', runtime: 'claude' }), - false, - ); - assert.equal( - formatHookCommandForRuntime('"C:/node.exe" "C:/hook.js"', { platform: 'win32', runtime: 'claude' }), - '"C:/node.exe" "C:/hook.js"', - ); - }); - - test('runtime omitted stays conservative (no PowerShell prefix)', () => { - assert.equal( - formatHookCommandForRuntime('"C:/node.exe" "C:/hook.js"', { platform: 'win32' }), - '"C:/node.exe" "C:/hook.js"', - ); - }); -}); - -describe('bug #3413: installer hook surfaces consume runtime-aware projection', () => { - test('buildHookCommand emits shell-neutral Claude hook command on Windows', () => { - const cmd = buildHookCommand('C:/Users/me/.claude', 'gsd-check-update.js', { - platform: 'win32', - runtime: 'claude', - }); - assert.equal(cmd.startsWith('& '), false, `Claude hook command must not use PowerShell prefix: ${cmd}`); - }); - - test('rewriteLegacyManagedNodeHookCommands removes stale PowerShell prefix for Claude on Windows', () => { - const settings = { - hooks: { - SessionStart: [{ - hooks: [{ type: 'command', command: '& "/usr/local/bin/node" "C:/Users/me/.claude/hooks/gsd-check-update.js"' }], - }], - }, - }; - const changed = rewriteLegacyManagedNodeHookCommands(settings, '"/usr/local/bin/node"', { - platform: 'win32', - runtime: 'claude', - }); - assert.equal(changed, true); - assert.equal( - settings.hooks.SessionStart[0].hooks[0].command, - '"/usr/local/bin/node" "C:/Users/me/.claude/hooks/gsd-check-update.js"', - ); - }); -}); - -describe('bug #3439: shell projection module owns managed-hook policy and legacy rewrite projection', () => { - test('isManagedHookBasename is surface-aware', () => { - assert.equal(isManagedHookBasename('/x/hooks/gsd-check-update.js', { surface: 'settings-json' }), true); - assert.equal(isManagedHookBasename('/x/hooks/gsd-statusline.js', { surface: 'settings-json' }), true); - assert.equal(isManagedHookBasename('/x/hooks/gsd-statusline.js', { surface: 'codex-toml' }), false); - assert.equal(isManagedHookBasename('/x/hooks/custom-hook.js', { surface: 'settings-json' }), false); - }); - - test('projectLegacySettingsHookCommand preserves non-Windows script token shape', () => { - const command = projectLegacySettingsHookCommand({ - absoluteRunner: '"/usr/local/bin/node"', - scriptPath: '/x/hooks/gsd-statusline.js', - scriptToken: "'/x/hooks/gsd-statusline.js'", - platform: 'linux', - runtime: 'claude', - }); - assert.equal(command, `"/usr/local/bin/node" '/x/hooks/gsd-statusline.js'`); - }); - - test('projectLegacySettingsHookCommand normalizes Windows managed paths and runtime wrapper policy', () => { - const command = projectLegacySettingsHookCommand({ - absoluteRunner: '"C:/nvm4w/nodejs/node.exe"', - scriptPath: 'C:\\Users\\me\\.gemini\\hooks\\gsd-prompt-guard.js', - scriptToken: "'C:\\Users\\me\\.gemini\\hooks\\gsd-prompt-guard.js'", - platform: 'win32', - runtime: 'gemini', - }); - assert.equal(command, '& "C:/nvm4w/nodejs/node.exe" "C:/Users/me/.gemini/hooks/gsd-prompt-guard.js"'); - }); - - test('projectLocalHookPrefix centralizes runtime-specific project-dir interpolation policy', () => { - assert.equal(projectLocalHookPrefix({ runtime: 'gemini', dirName: '.gemini' }), '.gemini'); - assert.equal(projectLocalHookPrefix({ runtime: 'antigravity', dirName: '.agents' }), '.agents'); - assert.equal( - projectLocalHookPrefix({ runtime: 'claude', dirName: '.claude' }), - '"$CLAUDE_PROJECT_DIR"/.claude', - ); - }); - - test('projectPortableHookBaseDir centralizes $HOME interpolation policy', () => { - assert.equal( - projectPortableHookBaseDir({ - configDir: '/Users/me/.claude', - homeDir: '/Users/me', - }), - '$HOME/.claude', - ); - assert.equal( - projectPortableHookBaseDir({ - configDir: 'C:\\Users\\me\\.claude', - homeDir: 'C:\\Users\\me', - }), - '$HOME/.claude', - ); - assert.equal( - projectPortableHookBaseDir({ - configDir: '/opt/custom/.claude', - homeDir: '/Users/me', - }), - '/opt/custom/.claude', - ); - }); - - test('isManagedHookCommand classifies managed settings hooks and leaves user commands untouched', () => { - assert.equal( - isManagedHookCommand('"/usr/local/bin/node" "/Users/me/.claude/hooks/gsd-statusline.js"', { - surface: 'settings-json', - }), - true, - ); - assert.equal( - isManagedHookCommand('"C:/Program Files/Git/bin/bash.exe" "C:/Users/me/.claude/hooks/gsd-session-state.sh"', { - surface: 'settings-json', - }), - true, - ); - assert.equal( - isManagedHookCommand('bash /Users/me/.claude/hooks/custom-lint.sh', { - surface: 'settings-json', - }), - false, - ); - }); - - test('isManagedHookCommand supports codex surfaces and optional legacy alias matching', () => { - const command = '"/usr/local/bin/node" "/Users/me/.codex/hooks/gsd-check-update.js"'; - assert.equal( - isManagedHookCommand(command, { - surface: 'codex-toml', - }), - true, - ); - assert.equal( - isManagedHookCommand('"/usr/local/bin/node" "/Users/me/.codex/hooks/gsd-update-check.js"', { - surface: 'codex-toml', - }), - false, - ); - assert.equal( - isManagedHookCommand('"/usr/local/bin/node" "/Users/me/.codex/hooks/gsd-update-check.js"', { - surface: 'codex-toml', - includeLegacyAliases: true, - }), - true, - ); - }); -}); - -describe('#1693 regression: Windows legacy-node rewrite must not double-quote a "$CLAUDE_PROJECT_DIR"-anchored local hook path', () => { - const winRunner = '"C:/Program Files/nodejs/node.exe"'; - - // WHY: a local-install hook path already carries a `"$CLAUDE_PROJECT_DIR"` - // anchored prefix (only the variable quoted, rest bare). On Windows the legacy - // rewrite previously JSON.stringify'd the whole token, yielding - // `"\"$CLAUDE_PROJECT_DIR\"/..."`. node then received an argument starting with - // a literal `"`, treated it as relative, and died with MODULE_NOT_FOUND — - // breaking every node managed hook at once (self-locking deadlock). - test('projectLegacySettingsHookCommand emits the anchored path verbatim, not re-quoted', () => { - const anchored = '"$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-context-monitor.js'; - const command = projectLegacySettingsHookCommand({ - absoluteRunner: winRunner, - scriptPath: anchored, - scriptToken: anchored, - platform: 'win32', - runtime: 'claude', - }); - assert.equal( - command, - '"C:/Program Files/nodejs/node.exe" "$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-context-monitor.js', - ); - assert.ok(!command.includes('\\"'), 'must not contain escaped double-quotes'); - }); - - // WHY: the fix must be surgical — a BARE absolute Windows path (no anchored - // prefix) can contain spaces ("Program Files") and still REQUIRES quoting. - test('projectLegacySettingsHookCommand still quotes a bare absolute Windows path', () => { - const abs = 'C:/Program Files App/.claude/hooks/gsd-context-monitor.js'; - const command = projectLegacySettingsHookCommand({ - absoluteRunner: winRunner, - scriptPath: abs, - scriptToken: JSON.stringify(abs), - platform: 'win32', - runtime: 'claude', - }); - assert.equal( - command, - '"C:/Program Files/nodejs/node.exe" "C:/Program Files App/.claude/hooks/gsd-context-monitor.js"', - ); - }); - - // WHY: the anchored short-circuit is scoped to win32. On POSIX the rewrite - // already preserved the caller's original `scriptToken` and never had the - // double-quote bug, so that behavior must be left byte-identical. scriptPath - // is anchored but scriptToken is a DISTINCT single-quoted value: if the - // win32 gate were removed, the anchored short-circuit would emit scriptPath - // and this assertion would fail — that is what pins the gate. - test('projectLegacySettingsHookCommand preserves the original scriptToken for anchored paths on POSIX', () => { - const command = projectLegacySettingsHookCommand({ - absoluteRunner: '"/usr/local/bin/node"', - scriptPath: '"$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-statusline.js', - scriptToken: "'/x/hooks/gsd-statusline.js'", - platform: 'linux', - runtime: 'claude', - }); - assert.equal(command, `"/usr/local/bin/node" '/x/hooks/gsd-statusline.js'`); - }); - - // WHY: end-to-end through the installer rewrite — the actual #2979 path that - // ran during the user's 1.5.0 -> 1.6.0 local update. Managed node hooks get - // the absolute runner + clean anchored path; a non-node-prefixed managed .sh - // hook (already correct) is left untouched. - test('rewriteLegacyManagedNodeHookCommands produces clean anchored node commands on Windows', () => { - const settings = { - hooks: { - PostToolUse: [ - { - hooks: [ - { command: 'node "$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-context-monitor.js' }, - ], - }, - ], - }, - }; - const changed = rewriteLegacyManagedNodeHookCommands(settings, winRunner, { - platform: 'win32', - runtime: 'claude', - }); - assert.equal(changed, true); - const rewritten = settings.hooks.PostToolUse[0].hooks[0].command; - assert.equal( - rewritten, - '"C:/Program Files/nodejs/node.exe" "$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-context-monitor.js', - ); - assert.ok(!rewritten.includes('\\"'), 'rewritten command must not contain escaped double-quotes'); - }); -}); diff --git a/tests/bug-3418-progress-flag-routing.test.cjs b/tests/bug-3418-progress-flag-routing.test.cjs deleted file mode 100644 index 2bf7dc97d..000000000 --- a/tests/bug-3418-progress-flag-routing.test.cjs +++ /dev/null @@ -1,33 +0,0 @@ -// allow-test-rule: source-text-is-the-product -// The command markdown is loaded directly by runtime prompt assembly. - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -describe('#3418: /gsd-progress flag routing prompt contract', () => { - test('progress command surfaces raw arguments on a dedicated line before routing parse', () => { - const command = fs.readFileSync( - path.join(__dirname, '..', 'commands', 'gsd', 'progress.md'), - 'utf8' - ); - - assert.ok( - command.includes('Arguments provided: "$ARGUMENTS"'), - 'progress.md must surface $ARGUMENTS on a dedicated line for stable flag parsing' - ); - }); - - test('progress command must not inline-substitute $ARGUMENTS into parse instruction text', () => { - const command = fs.readFileSync( - path.join(__dirname, '..', 'commands', 'gsd', 'progress.md'), - 'utf8' - ); - - assert.ok( - !command.includes('Parse the first token of $ARGUMENTS:'), - 'progress.md must keep parse instructions independent from argument interpolation' - ); - }); -}); diff --git a/tests/bug-3431-debug-command-yaml.test.cjs b/tests/bug-3431-debug-command-yaml.test.cjs deleted file mode 100644 index 66f59e4f8..000000000 --- a/tests/bug-3431-debug-command-yaml.test.cjs +++ /dev/null @@ -1,28 +0,0 @@ -// allow-test-rule: source-text-is-the-product -// Command markdown frontmatter is the deployed contract; this regression test -// verifies the real YAML surface that external parsers consume. - -'use strict'; - -const { test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const { parseFrontmatter } = require('./helpers.cjs'); - -const DEBUG_COMMAND_PATH = path.join(__dirname, '..', 'commands', 'gsd', 'debug.md'); - -function readFrontmatter(filePath) { - return parseFrontmatter(fs.readFileSync(filePath, 'utf8')); -} - -test('#3431: commands/gsd/debug.md frontmatter parses as YAML and preserves argument-hint', () => { - const frontmatter = readFrontmatter(DEBUG_COMMAND_PATH); - - assert.equal(frontmatter.name, 'gsd:debug'); - assert.equal( - frontmatter['argument-hint'], - '[list | status | continue | --diagnose] [issue description]', - 'argument-hint should remain user-visible text after YAML parsing' - ); -}); diff --git a/tests/bug-3441-path-action-projection.test.cjs b/tests/bug-3441-path-action-projection.test.cjs deleted file mode 100644 index dff34c31a..000000000 --- a/tests/bug-3441-path-action-projection.test.cjs +++ /dev/null @@ -1,136 +0,0 @@ -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const path = require('node:path'); -const fs = require('node:fs'); -const os = require('node:os'); - -const projection = require(path.join( - __dirname, - '..', - 'gsd-core', - 'bin', - 'lib', - 'shell-command-projection.cjs', -)); -const install = require(path.join(__dirname, '..', 'bin', 'install.js')); -const { withIsolatedProcessState, cleanup } = require('./helpers.cjs'); - -function createTempHome() { - return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-home-3441-')); -} - - -describe('bug #3441: PATH guidance is projected from typed shell action IR', () => { - test('projection module exports PATH action projection helper', () => { - assert.equal(typeof projection.projectPathActionProjection, 'function'); - }); - - // (formatSdkPathDiagnostic removed with the gsd-sdk shim, #191 — the PATH - // action projection it wrapped is still covered by the tests below.) - - test('persistent PATH export guidance is projected via the same seam', () => { - const posix = projection.projectPathActionProjection({ - mode: 'persist', - targetDir: '/tmp/with quote', - platform: 'linux', - }); - assert.ok(Array.isArray(posix.shellActions)); - assert.equal(posix.shellActions.length, 3); - assert.equal(posix.shellActions[0].label, 'zsh'); - assert.equal(posix.shellActions[1].label, 'bash'); - assert.equal(posix.shellActions[2].label, 'fish'); - assert.ok(posix.shellActions[0].command.includes('~/.zshrc')); - assert.ok(posix.shellActions[1].command.includes('~/.bashrc')); - // #323: fish gets a fish-native fish_add_path suggestion, not `export`. - assert.ok(posix.shellActions[2].command.startsWith('fish_add_path ')); - assert.ok(!posix.shellActions[2].command.includes('export')); - }); - - // #323 (ported from the closed #721): the fish suggestion is POSIX-only. - // On win32 the persist branch projects PowerShell / cmd.exe / Git Bash — - // no fish action — locking the POSIX-only contract. - test('no fish action is projected on win32', () => { - const win = projection.projectPathActionProjection({ - mode: 'persist', - targetDir: 'C:\\Users\\me\\AppData\\npm', - platform: 'win32', - }); - assert.ok(Array.isArray(win.shellActions)); - assert.equal( - win.shellActions.some((a) => a.shell === 'fish' || a.label === 'fish'), - false, - 'win32 persist projection must not include a fish action', - ); - assert.deepEqual( - win.shellActions.map((a) => a.label), - ['PowerShell', 'cmd.exe', 'Git Bash'], - ); - }); - - test('POSIX repair mode escapes double-quoted shell metacharacters', () => { - const projected = projection.projectPathActionProjection({ - mode: 'repair', - targetDir: '/tmp/qa\\"$HOME`tick', - platform: 'linux', - }); - assert.equal(projected.shellActions.length, 1); - assert.equal( - projected.shellActions[0].command, - 'export PATH="/tmp/qa\\\\\\"\\$HOME\\`tick:$PATH"', - ); - }); - - test('POSIX persist mode escapes single quotes for rc-file echo commands', () => { - const projected = projection.projectPathActionProjection({ - mode: 'persist', - targetDir: "/tmp/O'Neil/bin", - platform: 'linux', - }); - assert.equal(projected.shellActions[0].command.includes("/tmp/O'\\''Neil/bin"), true); - assert.equal(projected.shellActions[1].command.includes("/tmp/O'\\''Neil/bin"), true); - // #323: fish entry single-quotes the dir with the same POSIX literal - // escaping (`'\''` is also a valid escaped quote in fish unquoted context). - assert.equal(projected.shellActions[2].command, "fish_add_path '/tmp/O'\\''Neil/bin'"); - }); - - test('maybeSuggestPathExport renders commands projected by path-action seam', () => { - const home = createTempHome(); - try { - withIsolatedProcessState(() => { - const globalBin = path.join(home, '.npm-global', 'bin'); - fs.mkdirSync(globalBin, { recursive: true }); - fs.writeFileSync(path.join(home, '.zshrc'), 'export PATH="$HOME/.cargo/bin:$PATH"\n'); - process.env.PATH = ''; - - const expected = projection.projectPathActionProjection({ - mode: 'persist', - targetDir: globalBin, - platform: process.platform, - }); - - const logs = []; - const originalLog = console.log; - console.log = (...args) => logs.push(args.join(' ')); - try { - install.maybeSuggestPathExport(globalBin, home); - } finally { - console.log = originalLog; - } - - const joined = logs.join('\n'); - for (const action of expected.shellActions) { - assert.ok( - joined.includes(action.command), - `expected installer output to include projected command: ${action.command}\nOutput:\n${joined}`, - ); - } - }); - } finally { - cleanup(home); - } - }); -}); diff --git a/tests/bug-3584-runtime-slash-formatter.test.cjs b/tests/bug-3584-runtime-slash-formatter.test.cjs deleted file mode 100644 index 231b0045f..000000000 --- a/tests/bug-3584-runtime-slash-formatter.test.cjs +++ /dev/null @@ -1,388 +0,0 @@ -/** - * Regression tests for bug #3584 - * - * Runtime/user-facing strings emitted by gsd-core/bin/lib/*.cjs hardcoded - * the deprecated `/gsd:` colon form (16 files, ~50 occurrences). After - * #2808 unified GSD installs to register skills under the hyphen form - * (`name: gsd-execute-phase`), pasting the emitted `/gsd:execute-phase` into - * Claude Code yields `Unknown command: /gsd:execute-phase. Did you mean - * /gsd-execute-phase?`. Codex installs require `$gsd-` (shell-var) form. - * - * Fix: a runtime-aware slash formatter (`runtime-slash.cjs`) is now the single - * source of truth for emitting `/gsd-` (hyphen) for skills-based runtimes - * and `$gsd-` for Codex. Tests assert on the formatter's typed output and - * — for the integration tests in `bug-3584-runtime-slash-emitters.test.cjs` — - * on the structured `--json` payloads from the runtime command handlers. - */ - -'use strict'; - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const path = require('path'); - -const ROOT = path.join(__dirname, '..'); -const { formatGsdSlash, resolveRuntime } = require( - path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-slash.cjs'), -); -const { cleanup } = require('./helpers.cjs'); - -describe('formatGsdSlash — runtime-aware slash command formatter', () => { - describe('hyphen-form runtimes (claude, cursor, opencode, kilo, etc.)', () => { - test('emits /gsd- for claude', () => { - assert.strictEqual(formatGsdSlash('execute-phase', 'claude'), '/gsd-execute-phase'); - }); - - test('emits /gsd- for cursor', () => { - assert.strictEqual(formatGsdSlash('plan-phase', 'cursor'), '/gsd-plan-phase'); - }); - - test('emits /gsd- for opencode', () => { - assert.strictEqual(formatGsdSlash('discuss-phase', 'opencode'), '/gsd-discuss-phase'); - }); - - test('emits /gsd- for kilo', () => { - assert.strictEqual(formatGsdSlash('health', 'kilo'), '/gsd-health'); - }); - - test('unknown runtime defaults to hyphen form', () => { - assert.strictEqual( - formatGsdSlash('new-project', 'some-future-runtime'), - '/gsd-new-project', - ); - }); - - test('null/undefined runtime defaults to hyphen form (claude)', () => { - assert.strictEqual(formatGsdSlash('new-milestone', null), '/gsd-new-milestone'); - assert.strictEqual(formatGsdSlash('new-milestone', undefined), '/gsd-new-milestone'); - }); - - test('runtime aliases for non-codex runtimes still emit hyphen form', () => { - assert.strictEqual(formatGsdSlash('new-project', 'claude-code'), '/gsd-new-project'); - assert.strictEqual(formatGsdSlash('new-project', 'gemini-cli'), '/gsd-new-project'); - assert.strictEqual(formatGsdSlash('new-project', 'opencode-cli'), '/gsd-new-project'); - }); - }); - - describe('codex shell-var form', () => { - test('emits $gsd- for codex', () => { - assert.strictEqual(formatGsdSlash('execute-phase', 'codex'), '$gsd-execute-phase'); - }); - - test('emits $gsd- for codex aliases (app/cli)', () => { - assert.strictEqual(formatGsdSlash('execute-phase', 'codex-app'), '$gsd-execute-phase'); - assert.strictEqual(formatGsdSlash('execute-phase', 'codex_cli'), '$gsd-execute-phase'); - }); - - test('codex output is lowercased', () => { - assert.strictEqual( - formatGsdSlash('Execute-Phase', 'codex'), - '$gsd-execute-phase', - ); - }); - }); - - describe('input normalization', () => { - test('strips existing /gsd: colon prefix', () => { - assert.strictEqual( - formatGsdSlash('/gsd:execute-phase', 'claude'), - '/gsd-execute-phase', - ); - }); - - test('strips existing /gsd- hyphen prefix (idempotent)', () => { - assert.strictEqual( - formatGsdSlash('/gsd-plan-phase', 'claude'), - '/gsd-plan-phase', - ); - }); - - test('strips bare gsd: prefix without leading slash', () => { - assert.strictEqual( - formatGsdSlash('gsd:new-project', 'claude'), - '/gsd-new-project', - ); - }); - - test('strips existing $gsd- shell prefix (codex idempotent)', () => { - assert.strictEqual( - formatGsdSlash('$gsd-execute-phase', 'codex'), - '$gsd-execute-phase', - ); - }); - - test('runtime swap: /gsd:execute-phase + codex → $gsd-execute-phase', () => { - assert.strictEqual( - formatGsdSlash('/gsd:execute-phase', 'codex'), - '$gsd-execute-phase', - ); - }); - - test('case-insensitive prefix stripping', () => { - assert.strictEqual( - formatGsdSlash('GSD:execute-phase', 'claude'), - '/gsd-execute-phase', - ); - }); - }); - - describe('defensive returns for unsafe inputs', () => { - test('non-string commandName returns input unchanged', () => { - assert.strictEqual(formatGsdSlash(null, 'claude'), null); - assert.strictEqual(formatGsdSlash(undefined, 'claude'), undefined); - assert.strictEqual(formatGsdSlash(42, 'claude'), 42); - }); - - test('empty string returns empty string', () => { - assert.strictEqual(formatGsdSlash('', 'claude'), ''); - }); - - test('whitespace-only string returns empty string (no spurious /gsd- emission)', () => { - assert.strictEqual(formatGsdSlash(' ', 'claude'), ''); - assert.strictEqual(formatGsdSlash('\t\n', 'codex'), ''); - }); - - test('degenerate prefix-only input returns empty (does NOT re-emit colon form)', () => { - // Regression guard for the CodeRabbit finding on the original PR: - // a previous fallback returned `commandName` unchanged when the bare - // tail was empty, which re-introduced the deprecated `/gsd:` shape for - // inputs like `/gsd:`, `gsd:`, or `gsd-`. The formatter must never - // emit the colon form — return empty so callers detect "no command" - // instead of receiving an unroutable string. - assert.strictEqual(formatGsdSlash('/gsd:', 'claude'), ''); - assert.strictEqual(formatGsdSlash('gsd:', 'claude'), ''); - assert.strictEqual(formatGsdSlash('gsd-', 'claude'), ''); - assert.strictEqual(formatGsdSlash('/gsd-', 'codex'), ''); - assert.strictEqual(formatGsdSlash('$gsd-', 'codex'), ''); - }); - - test('commands with arguments preserve the argument tail', () => { - // `execute-phase 03` is a valid call shape — the formatter only - // rewrites the command token; everything after the first whitespace - // belongs to the caller. - assert.strictEqual( - formatGsdSlash('execute-phase 03', 'claude'), - '/gsd-execute-phase 03', - ); - assert.strictEqual( - formatGsdSlash('/gsd:execute-phase 03', 'claude'), - '/gsd-execute-phase 03', - ); - }); - - test('codex form lowercases only the command token, not the argument tail', () => { - // Regression for codex review finding: a previous implementation - // lowercased the full input including arguments, which would corrupt - // Windows paths and case-sensitive flag values passed as args. - assert.strictEqual( - formatGsdSlash('Map-Codebase --paths C:\\Users\\Me\\Project', 'codex'), - '$gsd-map-codebase --paths C:\\Users\\Me\\Project', - ); - assert.strictEqual( - formatGsdSlash('execute-phase 03 --Name FooBar', 'codex'), - '$gsd-execute-phase 03 --Name FooBar', - ); - }); - - test('hyphen form preserves token case (it does not get lowercased)', () => { - // Symmetry with codex: only codex lowercases the token. Hyphen-form - // runtimes preserve whatever case the caller supplied for the token. - assert.strictEqual( - formatGsdSlash('Plan-Phase 03', 'claude'), - '/gsd-Plan-Phase 03', - ); - }); - }); -}); - -describe('formatGsdSlash — descriptor-driven commandStyle (ADR-857 phase 5c)', () => { - // These three assertions are the non-vacuous equivalence anchor for the - // descriptor-driven branch: the formatter reads commandStyle from the - // capability registry instead of hardcoding `if (rt === 'codex')`. - - test('codex (commandStyle=shell-var) → $gsd- with lowercased token', () => { - // The registry carries commandStyle=shell-var for codex. The formatter - // must look it up and emit the shell-var form, lowercasing only the token. - assert.strictEqual(formatGsdSlash('Foo', 'codex'), '$gsd-foo'); - }); - - test('slash-hyphen runtime (claude) → /gsd- with case-preserved token', () => { - // claude descriptor has commandStyle=slash-hyphen. Token case is preserved. - assert.strictEqual(formatGsdSlash('Foo', 'claude'), '/gsd-Foo'); - }); - - test('slash-hyphen runtime (cursor) → /gsd- with case-preserved token', () => { - // cursor descriptor has commandStyle=slash-hyphen. - assert.strictEqual(formatGsdSlash('Foo', 'cursor'), '/gsd-Foo'); - }); - - test('unknown runtime (no registry entry) → /gsd- default (slash-hyphen fallback)', () => { - // An unknown runtime has no descriptor entry → runtimes[rt] is undefined → - // style is undefined → not 'shell-var' → falls through to /gsd- default. - assert.strictEqual(formatGsdSlash('foo', 'doesnotexist'), '/gsd-foo'); - }); -}); - -describe('formatGsdSlash — registry-parity: prefix and casing are a pure function of commandStyle', () => { - // Non-vacuous proof that the REGISTRY drives the decision, not a hardcoded - // `rt === 'codex'` check. For every runtime id in capability-registry.cjs we - // derive the expected prefix and lowercasing linkage directly from the - // registry's commandStyle field and assert that formatGsdSlash matches. - // - // This fails if: - // (a) anyone reverts the formatter to a hardcode that diverges from the - // registry (e.g. a future runtime gets commandStyle=shell-var but the - // formatter still only special-cases 'codex'); or - // (b) a runtime's commandStyle is changed in the registry without - // formatGsdSlash following suit. - const { runtimes } = require( - path.join(ROOT, 'gsd-core', 'bin', 'lib', 'capability-registry.cjs'), - ); - - const TOKEN_MIXED = 'SomeCmd'; // mixed-case to distinguish lowercasing behaviour - - for (const [id, descriptor] of Object.entries(runtimes)) { - const style = descriptor.runtime.commandStyle; - - test(`${id}: commandStyle=${style} → formatGsdSlash prefix and casing match registry`, () => { - const result = formatGsdSlash(TOKEN_MIXED, id); - - if (style === 'shell-var') { - // shell-var runtimes must emit $gsd- - assert.ok( - result.startsWith('$gsd-'), - `[${id}] expected $gsd- prefix (commandStyle=shell-var), got: ${result}`, - ); - assert.strictEqual( - result, - `$gsd-${TOKEN_MIXED.toLowerCase()}`, - `[${id}] shell-var must lowercase the token`, - ); - } else { - // slash-hyphen (or any other non-shell-var value) must emit /gsd- - // with case preserved (no lowercasing) - assert.ok( - result.startsWith('/gsd-'), - `[${id}] expected /gsd- prefix (commandStyle=${style}), got: ${result}`, - ); - assert.strictEqual( - result, - `/gsd-${TOKEN_MIXED}`, - `[${id}] slash-hyphen must preserve token case`, - ); - } - }); - } -}); - -describe('resolveRuntime — env > config > default', () => { - test('process.env.GSD_RUNTIME wins over everything', () => { - const saved = process.env.GSD_RUNTIME; - try { - process.env.GSD_RUNTIME = 'codex'; - assert.strictEqual(resolveRuntime(null), 'codex'); - assert.strictEqual(resolveRuntime('/nonexistent'), 'codex'); - } finally { - if (saved === undefined) delete process.env.GSD_RUNTIME; - else process.env.GSD_RUNTIME = saved; - } - }); - - test('canonicalizes codex env aliases', () => { - const saved = process.env.GSD_RUNTIME; - try { - process.env.GSD_RUNTIME = 'codex-app'; - assert.strictEqual(resolveRuntime(null), 'codex'); - process.env.GSD_RUNTIME = 'codex_cli'; - assert.strictEqual(resolveRuntime('/nonexistent'), 'codex'); - } finally { - if (saved === undefined) delete process.env.GSD_RUNTIME; - else process.env.GSD_RUNTIME = saved; - } - }); - - test('defaults to claude when env is unset and projectDir missing', () => { - const saved = process.env.GSD_RUNTIME; - try { - delete process.env.GSD_RUNTIME; - assert.strictEqual(resolveRuntime(null), 'claude'); - assert.strictEqual(resolveRuntime(undefined), 'claude'); - } finally { - if (saved !== undefined) process.env.GSD_RUNTIME = saved; - } - }); - - test('reads config.runtime when env is unset and projectDir has a config', (t) => { - const fs = require('fs'); - const os = require('os'); - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3584-')); - t.after(() => cleanup(tmp)); - - fs.mkdirSync(path.join(tmp, '.planning'), { recursive: true }); - fs.writeFileSync( - path.join(tmp, '.planning', 'config.json'), - JSON.stringify({ runtime: 'codex' }), - ); - - const saved = process.env.GSD_RUNTIME; - try { - delete process.env.GSD_RUNTIME; - assert.strictEqual(resolveRuntime(tmp), 'codex'); - } finally { - if (saved !== undefined) process.env.GSD_RUNTIME = saved; - } - }); - - test('canonicalizes codex config aliases', (t) => { - const fs = require('fs'); - const os = require('os'); - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3584-')); - t.after(() => cleanup(tmp)); - - fs.mkdirSync(path.join(tmp, '.planning'), { recursive: true }); - fs.writeFileSync( - path.join(tmp, '.planning', 'config.json'), - JSON.stringify({ runtime: 'codex-cli' }), - ); - - const saved = process.env.GSD_RUNTIME; - try { - delete process.env.GSD_RUNTIME; - assert.strictEqual(resolveRuntime(tmp), 'codex'); - } finally { - if (saved !== undefined) process.env.GSD_RUNTIME = saved; - } - }); - - test('canonicalizes non-codex config aliases', (t) => { - const fs = require('fs'); - const os = require('os'); - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3584-')); - t.after(() => cleanup(tmp)); - - fs.mkdirSync(path.join(tmp, '.planning'), { recursive: true }); - fs.writeFileSync( - path.join(tmp, '.planning', 'config.json'), - JSON.stringify({ runtime: 'claude-code' }), - ); - - const saved = process.env.GSD_RUNTIME; - try { - delete process.env.GSD_RUNTIME; - assert.strictEqual(resolveRuntime(tmp), 'claude'); - } finally { - if (saved !== undefined) process.env.GSD_RUNTIME = saved; - } - }); - - test('lowercases the resolved runtime', () => { - const saved = process.env.GSD_RUNTIME; - try { - process.env.GSD_RUNTIME = 'CLAUDE'; - assert.strictEqual(resolveRuntime(null), 'claude'); - } finally { - if (saved === undefined) delete process.env.GSD_RUNTIME; - else process.env.GSD_RUNTIME = saved; - } - }); -}); diff --git a/tests/bug-3608-antigravity-update-runtime-classification.test.cjs b/tests/bug-3608-antigravity-update-runtime-classification.test.cjs deleted file mode 100644 index 233389518..000000000 --- a/tests/bug-3608-antigravity-update-runtime-classification.test.cjs +++ /dev/null @@ -1,111 +0,0 @@ -/** - * Bug #3608: /gsd:update must model Antigravity as a first-class runtime, so an - * Antigravity install (~/.gemini/antigravity*) is not misclassified as base - * Gemini. - * - * The installer (bin/install.js) and SDK already treat Antigravity as a distinct - * runtime with its own config dirs, env var (ANTIGRAVITY_CONFIG_DIR), and CLI - * flag (--antigravity). The update flow must agree. - * - * Relocation (#498): the update flow's runtime/scope detection moved out of - * ~280 lines of inline bash in update.md into the tested projection - * `gsd-core/bin/lib/update-context.cjs` (resolveUpdateContext). The - * antigravity-first-class contract now lives there as data + behavior, so this - * test asserts it on the projection. The only piece still authored in update.md - * is the execution_context path classification (prose the agent applies), which - * this test still checks for antigravity-before-gemini ordering. - * - * Order matters: every probe list / env ladder that contains a Gemini entry - * MUST place the more-specific Antigravity entry first, else an install with - * both signals present falls through to gemini. - */ - -'use strict'; -process.env.GSD_TEST_MODE = '1'; - -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 { - RUNTIME_DIRS, - inferPreferredRuntime, - envRuntimeDirs, - resolveUpdateContext, -} = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'update-context.cjs')); -const UPDATE_MD = path.join(ROOT, 'gsd-core', 'workflows', 'update.md'); - -function runtimeOrder() { - return RUNTIME_DIRS.map(([rt]) => rt); -} -function firstIndex(arr, token) { - return arr.indexOf(token); -} - -describe('bug #3608 / #498: update-context models Antigravity as a first-class runtime', () => { - test('RUNTIME_DIRS lists antigravity before gemini', () => { - const order = runtimeOrder(); - const antIdx = firstIndex(order, 'antigravity'); - const gemIdx = firstIndex(order, 'gemini'); - assert.notStrictEqual(antIdx, -1, 'RUNTIME_DIRS missing antigravity'); - assert.notStrictEqual(gemIdx, -1, 'RUNTIME_DIRS missing gemini'); - assert.ok(antIdx < gemIdx, `antigravity (@${antIdx}) must precede gemini (@${gemIdx}) — first match wins`); - }); - - test('RUNTIME_DIRS includes antigravity 2.x (ide/cli) + legacy dirs', () => { - const dirs = RUNTIME_DIRS.filter(([rt]) => rt === 'antigravity').map(([, d]) => d); - assert.ok(dirs.includes('.gemini/antigravity-ide'), 'missing .gemini/antigravity-ide'); - assert.ok(dirs.includes('.gemini/antigravity-cli'), 'missing .gemini/antigravity-cli'); - assert.ok(dirs.includes('.gemini/antigravity'), 'missing legacy .gemini/antigravity fallback'); - // All antigravity dirs precede the .gemini probe. - const order = RUNTIME_DIRS.map(([, d]) => d); - const gemIdx = order.indexOf('.gemini'); - for (const d of dirs) { - assert.ok(order.indexOf(d) < gemIdx, `${d} must precede .gemini in the probe order`); - } - }); - - test('env inference recognizes ANTIGRAVITY_CONFIG_DIR before GEMINI_CONFIG_DIR', () => { - const rt = inferPreferredRuntime({ - fs: { exists: () => false }, - env: { ANTIGRAVITY_CONFIG_DIR: '/x', GEMINI_CONFIG_DIR: '/y' }, - preferredConfigDir: '', - }); - assert.equal(rt, 'antigravity', 'both env vars set must resolve to antigravity, not gemini'); - }); - - test('envRuntimeDirs emits an antigravity entry (before gemini) when ANTIGRAVITY_CONFIG_DIR is set', () => { - const entries = envRuntimeDirs({ env: { ANTIGRAVITY_CONFIG_DIR: '/x/ag', GEMINI_CONFIG_DIR: '/x/gem' }, home: '/home/u' }); - const order = entries.map(([rt]) => rt); - assert.ok(order.includes('antigravity'), 'expected an antigravity env candidate'); - assert.ok(order.indexOf('antigravity') < order.indexOf('gemini'), 'antigravity env candidate must precede gemini'); - }); - - test('behavioral: an Antigravity install resolves to runtime "antigravity", not "gemini"', () => { - // Normalize paths so the fake fs matches the resolver's path.join/resolve - // lookups on Windows (backslash + drive) as well as POSIX. - const normKey = (p) => path.resolve(p).replace(/\\/g, '/').toLowerCase(); - const HOME = '/home/u'; - const agDir = path.join(HOME, '.gemini', 'antigravity'); - const verFile = normKey(path.join(agDir, 'gsd-core', 'VERSION')); - const markerFile = normKey(path.join(agDir, 'gsd-core', 'workflows', 'update.md')); - const fakeFs = { - exists: (p) => normKey(p) === verFile || normKey(p) === markerFile, - readFile: (p) => (normKey(p) === verFile ? '1.40.0\n' : null), - }; - const r = resolveUpdateContext({ home: HOME, cwd: path.resolve('/work'), env: {}, fs: fakeFs }); - assert.equal(r.runtime, 'antigravity'); - assert.equal(normKey(r.gsdDir), normKey(agDir)); - }); - - test('update.md execution_context classification still lists antigravity paths before /.gemini/', () => { - const content = fs.readFileSync(UPDATE_MD, 'utf-8'); - const antIde = content.indexOf('/.gemini/antigravity-ide/'); - const gemBare = content.indexOf('`/.gemini/` -> `gemini`'); - assert.notStrictEqual(antIde, -1, 'update.md must document the antigravity-ide execution_context path'); - assert.notStrictEqual(gemBare, -1, 'update.md must document the bare /.gemini/ -> gemini classification'); - assert.ok(antIde < gemBare, 'antigravity path classification must precede the bare /.gemini/ rule'); - }); -}); diff --git a/tests/bug-3659-applysurface-prune-skill-dirs.test.cjs b/tests/bug-3659-applysurface-prune-skill-dirs.test.cjs deleted file mode 100644 index 4feda3126..000000000 --- a/tests/bug-3659-applysurface-prune-skill-dirs.test.cjs +++ /dev/null @@ -1,257 +0,0 @@ -'use strict'; -/** - * Regression test for bug #3659 - * - * applySurface did not prune ~/.claude/skills/gsd-STEM dirs when a cluster - * was disabled. install/uninstall both prune correctly via _removeGsdEntries; - * applySurface called _syncGsdDir with the right logic but the surface.md spec - * directed the AI to use RUNTIME_CONFIG_DIR=~/.claude/skills (the skills dir - * itself) instead of the base Claude config dir (~/.claude). - * - * When runtimeConfigDir = ~/.claude/skills and scope = 'global': - * kind.destSubpath = 'skills' - * dest = path.join('~/.claude/skills', 'skills') = ~/.claude/skills/skills WRONG - * - * The pruning ran against the wrong (non-existent) dir so stale gsd-Y dirs - * were never removed from ~/.claude/skills/. - * - * Fix: - * 1. surface.md RUNTIME_CONFIG_DIR changed to use the base Claude config dir - * (getGlobalDir('claude') = ~/.claude), not ~/.claude/skills. - * 2. Surface state file moves to /.gsd-surface.json at the config - * root, matching install/uninstall conventions. - * 3. applySurface is called with scope='global' so the skills kind is active. - * - * Tests: - * a) disabled cluster gsd-STEM dirs are REMOVED from ~/.claude/skills/ - * b) gsd-STEM dirs in the retain set are preserved - * c) non-gsd dirs are UNTOUCHED (user-owned) - * d) idempotence: running applySurface twice produces the same on-disk state - */ - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const { writeSurface, applySurface } = require('../gsd-core/bin/lib/surface.cjs'); -const { loadSkillsManifest } = require('../gsd-core/bin/lib/install-profiles.cjs'); -const { CLUSTERS } = require('../gsd-core/bin/lib/clusters.cjs'); -const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); -const { createTempDir, cleanup } = require('./helpers.cjs'); - -const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); - -/** - * Build a minimal fixture simulating a Claude global install. - * - * configDir — analogous to ~/.claude - * skillsDir — analogous to ~/.claude/skills (contains gsd-* dirs) - * - * Pre-populated with: - * gsd-explore/SKILL.md — in research_ideate cluster (will be disabled) - * gsd-help/SKILL.md — in core_loop cluster (will remain enabled) - * my-custom-skill/ — user-owned, not gsd-prefixed (must never be touched) - */ -function createFixture() { - const configDir = createTempDir('gsd-bug3659-'); - const skillsDir = path.join(configDir, 'skills'); - fs.mkdirSync(skillsDir, { recursive: true }); - - const gsdExplore = path.join(skillsDir, 'gsd-explore'); - const gsdHelp = path.join(skillsDir, 'gsd-help'); - const userSkill = path.join(skillsDir, 'my-custom-skill'); - - for (const d of [gsdExplore, gsdHelp, userSkill]) { - fs.mkdirSync(d, { recursive: true }); - fs.writeFileSync(path.join(d, 'SKILL.md'), '# skill\n', 'utf8'); - } - - return { configDir, skillsDir, gsdExplore, gsdHelp, userSkill }; -} - -/** - * Extended fixture that also includes a user-created gsd-* directory. - * Used by the all-clusters-disabled counter-test to prove the manifest-membership - * gate (Finding 1 fix) protects user-owned gsd-* dirs from data loss. - */ -function createFixtureWithUserGsdDir() { - const base = createFixture(); - const userGsdDir = path.join(base.skillsDir, 'gsd-mything'); - fs.mkdirSync(userGsdDir, { recursive: true }); - fs.writeFileSync(path.join(userGsdDir, 'SKILL.md'), '# user skill\n', 'utf8'); - return { ...base, userGsdDir }; -} - -describe('bug-3659: applySurface prunes ~/.claude/skills/gsd-*/ on cluster disable', () => { - test('(a) disabled cluster gsd-* dirs are removed from skills dir', (t) => { - const { configDir, gsdExplore, gsdHelp } = createFixture(); - t.after(() => cleanup(configDir)); - - // Surface state at configDir (= ~/.claude), NOT at skillsDir (= ~/.claude/skills). - // This is the corrected location after the fix. - writeSurface(configDir, { - baseProfile: 'full', - disabledClusters: ['research_ideate'], // contains 'explore' - explicitAdds: [], - explicitRemoves: [], - }); - - const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); - // scope='global' gives the skills kind for Claude (destSubpath='skills', prefix='gsd-') - const layout = resolveRuntimeArtifactLayout('claude', configDir, 'global'); - applySurface(configDir, layout, manifest, CLUSTERS); - - // gsd-explore is in the research_ideate cluster which was disabled: - // it must be pruned from skillsDir. - assert.ok( - !fs.existsSync(gsdExplore), - 'gsd-explore/ must be removed from skills dir when research_ideate cluster is disabled' - ); - - // gsd-help is in core_loop (not disabled) and must survive. - assert.ok( - fs.existsSync(gsdHelp), - 'gsd-help/ must be preserved when its cluster is not disabled' - ); - }); - - test('(b) gsd-* dirs in retained clusters are preserved', (t) => { - const { configDir, gsdHelp } = createFixture(); - t.after(() => cleanup(configDir)); - - // Disable a cluster that does NOT include help (core_loop has help) - writeSurface(configDir, { - baseProfile: 'full', - disabledClusters: ['research_ideate'], - explicitAdds: [], - explicitRemoves: [], - }); - - const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); - const layout = resolveRuntimeArtifactLayout('claude', configDir, 'global'); - applySurface(configDir, layout, manifest, CLUSTERS); - - assert.ok( - fs.existsSync(gsdHelp), - 'gsd-help/ must be preserved — core_loop cluster remains enabled' - ); - }); - - test('(c) non-gsd user dirs are untouched', (t) => { - const { configDir, userSkill } = createFixture(); - t.after(() => cleanup(configDir)); - - writeSurface(configDir, { - baseProfile: 'full', - disabledClusters: ['research_ideate'], - explicitAdds: [], - explicitRemoves: [], - }); - - const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); - const layout = resolveRuntimeArtifactLayout('claude', configDir, 'global'); - applySurface(configDir, layout, manifest, CLUSTERS); - - assert.ok( - fs.existsSync(userSkill), - 'my-custom-skill/ (non-gsd user dir) must be preserved by applySurface' - ); - assert.ok( - fs.existsSync(path.join(userSkill, 'SKILL.md')), - 'user skill SKILL.md must be untouched' - ); - }); - - test('(d) idempotence: running applySurface twice produces identical on-disk state', (t) => { - const { configDir, skillsDir, gsdExplore, userSkill } = createFixture(); - t.after(() => cleanup(configDir)); - - writeSurface(configDir, { - baseProfile: 'full', - disabledClusters: ['research_ideate'], - explicitAdds: [], - explicitRemoves: [], - }); - - const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); - const layout = resolveRuntimeArtifactLayout('claude', configDir, 'global'); - - // First apply - applySurface(configDir, layout, manifest, CLUSTERS); - const afterFirst = fs.readdirSync(skillsDir).sort(); - - // Second apply — must produce exactly the same set - applySurface(configDir, layout, manifest, CLUSTERS); - const afterSecond = fs.readdirSync(skillsDir).sort(); - - assert.deepStrictEqual( - afterSecond, - afterFirst, - 'skills dir contents must be identical after two consecutive applySurface calls (idempotent)' - ); - - // Double-check the pruned dir is gone after both runs - assert.ok( - !fs.existsSync(gsdExplore), - 'gsd-explore/ must remain absent after second applySurface call' - ); - - // User dir must survive both runs - assert.ok( - fs.existsSync(userSkill), - 'my-custom-skill/ must survive both applySurface calls' - ); - }); - - test('(e) all-clusters-disabled: all gsd-owned dirs removed; user dirs and user gsd-* dirs survive', (t) => { - // Counter-test for Finding 1 (data-loss class) and Finding 3 (missing coverage). - // - // Disables EVERY cluster so the resolved skill set is empty. - // Assertions: - // 1. gsd-explore/ — GSD-owned, disabled cluster → REMOVED - // 2. gsd-help/ — GSD-owned, disabled cluster → REMOVED - // 3. my-custom-skill/ — user-owned, no gsd- prefix → PRESERVED - // 4. gsd-mything/ — prefix match but NOT in manifest → PRESERVED (Finding 1 fix) - const { configDir, gsdExplore, gsdHelp, userSkill, userGsdDir } = - createFixtureWithUserGsdDir(); - t.after(() => cleanup(configDir)); - - const allClusters = Object.keys(CLUSTERS); - - writeSurface(configDir, { - baseProfile: 'full', - disabledClusters: allClusters, - explicitAdds: [], - explicitRemoves: [], - }); - - const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); - const layout = resolveRuntimeArtifactLayout('claude', configDir, 'global'); - applySurface(configDir, layout, manifest, CLUSTERS); - - // 1. GSD-owned dirs in now-disabled clusters must be removed. - assert.ok( - !fs.existsSync(gsdExplore), - 'gsd-explore/ must be removed when all clusters are disabled' - ); - assert.ok( - !fs.existsSync(gsdHelp), - 'gsd-help/ must be removed when all clusters are disabled' - ); - - // 2. Non-gsd user dir must be preserved regardless. - assert.ok( - fs.existsSync(userSkill), - 'my-custom-skill/ (non-gsd user dir) must survive when all clusters are disabled' - ); - - // 3. User-created gsd-* dir NOT in the manifest must be preserved. - // This is the critical Finding 1 regression guard: without the manifest-membership - // gate, gsd-mything/ would have been silently deleted. - assert.ok( - fs.existsSync(userGsdDir), - 'gsd-mything/ (user-created gsd-* dir not in manifest) must be preserved — ' + - 'prefix match alone must not trigger deletion (Finding 1 data-loss fix)' - ); - }); -}); diff --git a/tests/bug-3683-command-cross-reference-invariant.test.cjs b/tests/bug-3683-command-cross-reference-invariant.test.cjs deleted file mode 100644 index 1c361b7ff..000000000 --- a/tests/bug-3683-command-cross-reference-invariant.test.cjs +++ /dev/null @@ -1,70 +0,0 @@ -// allow-test-rule: source-text-is-the-product -// commands/gsd/*.md bodies are the deployed contract — cross-references between -// them must stay coherent. This test inspects .md source to enforce the invariant. - -'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 COMMANDS_DIR = path.resolve(__dirname, '..', 'commands', 'gsd'); - -function readKnownTargets() { - const commandNames = fs.readdirSync(COMMANDS_DIR) - .filter(f => f.endsWith('.md')) - .map(f => f.slice(0, -3)); - return { commandNames, knownTargets: new Set(commandNames) }; -} - -function stripFrontmatter(src) { - return src.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, ''); -} - -// Word-boundary lookbehind matching fix-slash-commands.cjs buildColonPattern / buildPattern -// Excludes path-y characters (~, ., /) so `~/gsd-workspaces`, `./gsd-foo`, `path/gsd-bar` don't match. -// Trailing `(?![\w-]*\/)` rejects filesystem path segments like `${VAR}/gsd-core/bin` (the -// runtime-launcher shim) where a non-path char (e.g. `}`) precedes `/gsd-core/` — those are -// directory paths to the gsd-core/ runtime, not slash-command references (#604 rename). -const REF_PATTERN = /(? { - test('all /gsd: and /gsd- body refs resolve to known command base-names', () => { - const { commandNames, knownTargets: knownSet } = readKnownTargets(); - const mdFiles = commandNames.sort().map(n => path.join(COMMANDS_DIR, `${n}.md`)); - - const failures = []; - - for (const filePath of mdFiles) { - const src = fs.readFileSync(filePath, 'utf-8'); - const body = stripFrontmatter(src); - const lines = body.split('\n'); - const relFile = path.relative(path.resolve(__dirname, '..'), filePath); - - lines.forEach((line, idx) => { - REF_PATTERN.lastIndex = 0; - let m; - while ((m = REF_PATTERN.exec(line)) !== null) { - const ref = m[1]; - if (!knownSet.has(ref)) { - const sep = m[0].includes(':') ? ':' : '-'; - failures.push({ - file: relFile, - line: idx + 1, - ref: `/gsd${sep}${ref}`, - excerpt: line.trim(), - }); - } - } - }); - } - - if (failures.length > 0) { - const msg = failures - .map(f => ` ${f.file}:${f.line} — dangling ref "${f.ref}" — ${f.excerpt}`) - .join('\n'); - assert.fail(`Dangling command cross-references found:\n${msg}`); - } - }); -}); diff --git a/tests/bug-3706-ui-safety-gate-false-positives.test.cjs b/tests/bug-3706-ui-safety-gate-false-positives.test.cjs deleted file mode 100644 index dcf0f2d8b..000000000 --- a/tests/bug-3706-ui-safety-gate-false-positives.test.cjs +++ /dev/null @@ -1,398 +0,0 @@ -'use strict'; - -/** - * Tests for bug #3706 (word-boundary anchoring) and #3718 (cross-shell portability). - * - * Root cause (#3706): grep -iE "UI|..." had no word-boundary anchoring, causing - * false-positives on "Requirements" (contains "ui"), "overview" ("view"), etc. - * - * Root cause (#3718): shell-based invocation (with locale env-var prefix) silently - * degrades on Windows PowerShell — the prefix is not recognised by pwsh. - * - * Fix (#3718, Approach A): gate logic moved to `bin/lib/ui-safety-gate.cjs` (Node.js). - * Reads phase text from STDIN (not argv) to avoid OS ARG_MAX limits. - * Invoked as: printf '%s' "$PHASE_SECTION" | node "${GSD_REPO_ROOT}/bin/lib/ui-safety-gate.cjs" - * Path anchored to repo root via `git rev-parse --show-toplevel`. - * - * Test strategy: - * 1. Import the helper module directly and assert correct results on the full fixture - * matrix (exercises the production code path). - * 2. Spawn the helper as a child process with shell:false and input on stdin to prove - * cross-shell portability — spawnSync with shell:false bypasses any host shell. - * 3. Assert the workflow .md files now invoke `node ... ui-safety-gate.cjs` via stdin - * rather than the old shell-based invocation (structural guard against regression). - */ - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); -const { spawnSync } = require('node:child_process'); -const os = require('node:os'); -const { cleanup } = require('./helpers.cjs'); - -const HELPER_PATH = path.join(__dirname, '..', 'bin', 'lib', 'ui-safety-gate.cjs'); -const PLAN_PHASE_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'plan-phase.md'); -const AUTONOMOUS_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'autonomous.md'); - -const { checkUiPresence } = require(HELPER_PATH); - -// ── Helper ──────────────────────────────────────────────────────────────────── - -/** - * Mirrors the old `hasUiGate` helper for backwards-compatible assertions. - * Returns 0 (UI found) or 1 (not found), matching grep exit code semantics. - */ -function hasUiGate(text) { - return checkUiPresence(text).hasUI ? 0 : 1; -} - -/** - * Spawn the helper with shell:false, passing input via stdin. - * Returns the spawnSync result object. - */ -function spawnGate(input) { - return spawnSync(process.execPath, [HELPER_PATH], { - shell: false, - encoding: 'utf-8', - input: input, - }); -} - -// ── Structural guard — workflow files now invoke Node via stdin ─────────────── - -describe('Workflow .md structural guard (#3718)', () => { - // allow-test-rule: source-text-is-the-product - // The UI safety gate invocation is embedded in workflow prose-as-code. - // These structural tests guard against regression where someone re-introduces - // the shell-based locale-prefix invocation that silently breaks on Windows PowerShell. - - // plan-phase.md (post-#1026): §5.6 now delegates UI gate evaluation to - // `gsd_run check ui-plan-gate` (a CLI command that internally calls checkUiPresence - // from ui-safety-gate.cjs). The direct shell invocation of ui-safety-gate.cjs was - // intentionally removed from the workflow — cross-shell portability is now provided - // by the CLI command layer, not inline shell code. - test('plan-phase.md must invoke check ui-plan-gate (capability-driven UI gate — #1026)', () => { - const content = fs.readFileSync(PLAN_PHASE_PATH, 'utf-8'); - assert.ok( - content.includes('check ui-plan-gate'), - 'plan-phase.md: §5.6 must delegate to `check ui-plan-gate` (capability-driven, #1026)' - ); - assert.ok( - !content.includes('LC_ALL=C grep'), - 'plan-phase.md: must NOT contain LC_ALL=C grep — that silently fails on Windows PowerShell (#3718)' - ); - // Must NOT reintroduce the old shell-based path-search loop for ui-safety-gate.cjs - assert.ok( - !content.includes('UI_GATE_JS=$(for _c in'), - 'plan-phase.md: must NOT contain the old shell-based ui-safety-gate.cjs path-search (old §5.6 pattern)' - ); - }); - - // autonomous.md §3a.5 (post-#1031 cutover): §3a.5 now delegates to - // `loop render-hooks plan:pre` + `check ui-plan-gate` (capability-driven pattern, - // matching plan-phase.md §5.6). The direct shell invocation of ui-safety-gate.cjs - // was intentionally removed — cross-shell portability is provided by the CLI layer. - test('autonomous.md must invoke check ui-plan-gate (capability-driven UI gate — #1031)', () => { - const label = 'autonomous.md'; - const content = fs.readFileSync(AUTONOMOUS_PATH, 'utf-8'); - - // §3a.5 must delegate to the capability-driven gate, not inline shell code - assert.ok( - content.includes('check ui-plan-gate'), - `${label}: §3a.5 must delegate to \`check ui-plan-gate\` (capability-driven, #1031)` - ); - // §3a.5 must dispatch render-hooks plan:pre - assert.ok( - content.includes('loop render-hooks plan:pre'), - `${label}: §3a.5 must dispatch \`loop render-hooks plan:pre\` (capability hook resolution)` - ); - // Must NOT reintroduce the old shell-based path-search loop for ui-safety-gate.cjs - assert.ok( - !content.includes('ui-safety-gate.cjs'), - `${label}: must NOT inline ui-safety-gate.cjs invocation — replaced by check ui-plan-gate (#1031)` - ); - assert.ok( - !content.includes('UI_GATE_JS=$(for _c in'), - `${label}: must NOT contain the old shell-based ui-safety-gate.cjs path-search (old §3a.5 pattern)` - ); - assert.ok( - !content.includes('LC_ALL=C grep'), - `${label}: must NOT contain LC_ALL=C grep — that silently fails on Windows PowerShell (#3718)` - ); - }); -}); - -// ── Cross-shell spawn test (#3718) ──────────────────────────────────────────── - -describe('Cross-shell portability — spawnSync with shell:false, stdin (#3718)', () => { - test('spawn with shell:false + stdin: UI input exits 0', () => { - const result = spawnGate('UI Refactor: migrate all screens'); - assert.strictEqual(result.status, 0, `Expected exit 0 (UI found), got ${result.status}. stderr: ${result.stderr}`); - }); - - test('spawn with shell:false + stdin: non-UI input exits 1', () => { - const result = spawnGate('Requirements: backend REST API only'); - assert.strictEqual(result.status, 1, `Expected exit 1 (no UI), got ${result.status}. stderr: ${result.stderr}`); - }); - - test('spawn with shell:false + stdin: empty input exits 1', () => { - const result = spawnGate(''); - assert.strictEqual(result.status, 1, `Expected exit 1 (no UI for empty input), got ${result.status}. stderr: ${result.stderr}`); - }); - - test('spawn with shell:false + stdin: CRLF line endings handled correctly', () => { - const result = spawnGate('Deploy to the cloud platform\r\nand configure CI/CD.'); - assert.strictEqual(result.status, 1, `CRLF "platform" must not trigger gate. stderr: ${result.stderr}`); - }); - - test('spawn with shell:false + stdin: multi-line input with UI token exits 0', () => { - const multiLine = 'Backend setup\nBuild the analytics dashboard\nand navigation component.'; - const result = spawnGate(multiLine); - assert.strictEqual(result.status, 0, `Multi-line input with "dashboard" must exit 0. stderr: ${result.stderr}`); - }); - - test('spawn with shell:false + stdin: very large input (>100KB) is handled without error', () => { - // Verifies stdin transport does not hit ARG_MAX (argv transport fails above ~1MB on macOS). - // Fixture uses pure backend prose — no standalone UI tokens. - const largeInput = 'Backend service deployment and database migration step.\n'.repeat(3000); - const result = spawnGate(largeInput); - assert.strictEqual(result.status, 1, `Large non-UI input must exit 1, not crash. status: ${result.status}, stderr: ${result.stderr}`); - }); - - test('spawn with shell:false + stdin: large input with UI token exits 0', () => { - const largeUiInput = 'Backend infrastructure setup.\n'.repeat(2999) + 'Build the analytics dashboard.\n'; - const result = spawnGate(largeUiInput); - assert.strictEqual(result.status, 0, `Large input ending with UI token must exit 0. stderr: ${result.stderr}`); - }); -}); - -// ── Behavioral test matrix (via module import) ──────────────────────────────── - -/** - * Full fixture matrix — exercises the production checkUiPresence() function directly. - */ -function runBehavioralTests(label) { - describe(`${label} — UI gate behavioral matrix`, () => { - - // ── False-positive tests (must NOT match) ────────────────────────────── - - test('"Requirements" must NOT trigger UI gate (bug #3706 — "ui" substring)', () => { - const phaseSection = - '**Requirements**: The service must expose REST endpoints for authentication.\n' + - 'All work is server-side. Database migrations and API contract only.'; - assert.strictEqual(hasUiGate(phaseSection), 1, '"Requirements" must not match the UI gate'); - }); - - test('"overview" must NOT trigger UI gate ("view" is a substring)', () => { - assert.strictEqual(hasUiGate('Overview of the data pipeline architecture and backend services.'), 1); - }); - - test('"performance" must NOT trigger UI gate ("form" is a substring)', () => { - assert.strictEqual(hasUiGate('Performance testing and benchmark analysis for the API layer.'), 1); - }); - - test('"platform" must NOT trigger UI gate ("form" is a substring)', () => { - assert.strictEqual(hasUiGate('Deploy to the cloud platform and configure CI/CD.'), 1); - }); - - test('"transform" must NOT trigger UI gate ("form" is a substring)', () => { - assert.strictEqual(hasUiGate('Transform raw event data and write to the warehouse.'), 1); - }); - - test('"review" must NOT trigger UI gate ("view" is a substring)', () => { - assert.strictEqual(hasUiGate('Code review checklist and PR approval workflow for the API.'), 1); - }); - - test('"build" must NOT trigger UI gate ("ui" at positions 2-3 of "build")', () => { - assert.strictEqual(hasUiGate('Build the backend service and run integration tests.'), 1); - }); - - test('"screening" must NOT trigger UI gate ("screen" is a substring)', () => { - assert.strictEqual(hasUiGate('Implement candidate screening criteria for the hiring pipeline.'), 1); - }); - - test('empty input does NOT trigger UI gate', () => { - assert.strictEqual(hasUiGate(''), 1); - }); - - test('whitespace-only input does NOT trigger UI gate', () => { - assert.strictEqual(hasUiGate(' \n \t '), 1); - }); - - test('compound "microfrontend" (no separator) does NOT trigger gate — documented behavior', () => { - assert.strictEqual( - hasUiGate('Build the microfrontend shell application.'), 1, - '"microfrontend" compound must NOT trigger gate' - ); - }); - - // ── True-positive tests (must match) ────────────────────────────────── - - test('standalone "UI" token DOES trigger UI gate', () => { - assert.strictEqual(hasUiGate('UI Refactor: migrate all screens to the new design system.'), 0); - }); - - test('standalone "view" token DOES trigger UI gate', () => { - assert.strictEqual(hasUiGate('Implement the user profile view controller and associated screen.'), 0); - }); - - test('standalone "form" token DOES trigger UI gate', () => { - assert.strictEqual(hasUiGate('Build a sign-up form with client-side validation.'), 0); - }); - - test('"dashboard" DOES trigger UI gate', () => { - assert.strictEqual(hasUiGate('Build the analytics dashboard and navigation component.'), 0); - }); - - test('lowercase "ui" token DOES trigger UI gate (case-insensitive)', () => { - assert.strictEqual(hasUiGate('Redesign the ui for mobile responsiveness.'), 0); - }); - - test('"non-UI" hyphenated form DOES trigger UI gate (hyphen is a word boundary)', () => { - assert.strictEqual(hasUiGate('This is a non-UI backend service with no visual elements.'), 0); - }); - - test('standalone "screen" token DOES trigger UI gate', () => { - assert.strictEqual(hasUiGate('Implement the loading screen and splash animation.'), 0); - }); - - test('hyphenated "micro-frontend" DOES trigger gate (word boundary on hyphen)', () => { - assert.strictEqual(hasUiGate('Build the micro-frontend shell application.'), 0); - }); - - // ── CRLF / edge case tests ───────────────────────────────────────────── - - test('CRLF line endings: non-UI text does not trigger gate', () => { - assert.strictEqual(hasUiGate('Deploy to the cloud platform\r\nand configure CI/CD.'), 1); - }); - - test('CRLF line endings: UI token on second line triggers gate', () => { - assert.strictEqual(hasUiGate('Backend setup complete.\r\nBuild the analytics dashboard.'), 0); - }); - - test('leading/trailing whitespace does not affect token detection', () => { - assert.strictEqual(hasUiGate(' UI refactor phase '), 0); - }); - }); -} - -runBehavioralTests('ui-safety-gate.cjs'); - -// ── Install-dir resolution from a consuming project (#448) ──────────────────── - -describe('UI gate resolves the helper against RUNTIME_DIR, not the consuming repo (#448)', () => { - const REPO_ROOT = path.join(__dirname, '..'); - - // Mirrors the §5.6 / §3a.5 resolution. The structural guard above forces the - // workflows to keep using this RUNTIME_DIR-anchored form; this proves the - // candidate path is correct and the helper is actually found + executed when - // the CWD is a consuming project that has no bin/lib of its own. - const GATE_SNIPPET = [ - '_GSD_RT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"', - 'UI_GATE_JS=$(for _c in "$_GSD_RT/gsd-core/bin/lib/ui-safety-gate.cjs" "$_GSD_RT/bin/lib/ui-safety-gate.cjs" "$_GSD_RT/.claude/bin/lib/ui-safety-gate.cjs" "$HOME/.claude/gsd-core/bin/lib/ui-safety-gate.cjs" "$HOME/.claude/bin/lib/ui-safety-gate.cjs"; do [ -f "$_c" ] && { echo "$_c"; break; }; done)', - 'if [ -n "$UI_GATE_JS" ]; then printf \'%s\' "$PHASE_SECTION" | node "$UI_GATE_JS" >/dev/null 2>&1; HAS_UI=$?; else HAS_UI=0; fi', - 'echo "$HAS_UI"', - ].join('\n'); - - function runGateFrom(consumingDir, phaseSection) { - return spawnSync('bash', ['-c', GATE_SNIPPET], { - cwd: consumingDir, - encoding: 'utf-8', - env: { ...process.env, RUNTIME_DIR: REPO_ROOT, PHASE_SECTION: phaseSection }, - }); - } - - test('UI text is detected (HAS_UI=0) from a project without bin/lib', () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-consuming-')); - try { - const res = runGateFrom(tmp, 'UI Refactor: migrate all screens'); - assert.strictEqual(res.status, 0, `bash failed: ${res.stderr}`); - assert.strictEqual(res.stdout.trim(), '0', - 'helper must be found via RUNTIME_DIR and report UI present — not silently no-op'); - } finally { - cleanup(tmp); - } - }); - - test('non-UI text returns HAS_UI=1 via the RUNTIME_DIR-resolved helper', () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-consuming-')); - try { - const res = runGateFrom(tmp, 'Requirements: backend REST API only'); - assert.strictEqual(res.stdout.trim(), '1'); - } finally { - cleanup(tmp); - } - }); - - test('UI gate found via gsd-core/bin/lib/ in installed layout (no root bin/lib/)', () => { - // Regression for #448: installed RUNTIME_DIR has gsd-core/bin/lib/ but NOT root bin/lib/. - // The probe must find the helper at the installed path, not silently no-op to HAS_UI=0. - const fakeRuntime = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-installed-rt-')); - const consumingProject = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-consuming-')); - try { - const installedLibDir = path.join(fakeRuntime, 'gsd-core', 'bin', 'lib'); - fs.mkdirSync(installedLibDir, { recursive: true }); - fs.copyFileSync( - path.join(REPO_ROOT, 'gsd-core', 'bin', 'lib', 'ui-safety-gate.cjs'), - path.join(installedLibDir, 'ui-safety-gate.cjs') - ); - - const res = spawnSync('bash', ['-c', GATE_SNIPPET], { - cwd: consumingProject, - encoding: 'utf-8', - env: { ...process.env, RUNTIME_DIR: fakeRuntime, PHASE_SECTION: 'Build the analytics dashboard' }, - }); - assert.strictEqual(res.status, 0, `bash failed: ${res.stderr}`); - assert.strictEqual(res.stdout.trim(), '0', - 'helper must be found via gsd-core/bin/lib/ in installed layout and report UI present'); - } finally { - cleanup(fakeRuntime); - cleanup(consumingProject); - } - }); -}); - -// ── checkUiPresence() return value API ─────────────────────────────────────── - -describe('checkUiPresence() return value API', () => { - test('returns { hasUI: true, tokens: [...] } when UI found', () => { - const result = checkUiPresence('Build the dashboard and UI form'); - assert.strictEqual(result.hasUI, true); - assert.ok(Array.isArray(result.tokens), 'tokens must be an array'); - assert.ok(result.tokens.length > 0, 'tokens must be non-empty when hasUI is true'); - assert.ok(result.tokens.includes('dashboard') || result.tokens.includes('ui') || result.tokens.includes('form')); - }); - - test('returns { hasUI: false, tokens: [] } when no UI found', () => { - const result = checkUiPresence('Requirements: backend REST API only'); - assert.strictEqual(result.hasUI, false); - assert.deepStrictEqual(result.tokens, []); - }); - - test('tokens are lowercased', () => { - const result = checkUiPresence('UI Refactor'); - assert.ok(result.tokens.every(t => t === t.toLowerCase()), 'All tokens must be lowercase'); - }); - - test('tokens are deduplicated', () => { - const result = checkUiPresence('UI redesign and ui cleanup'); - const uiCount = result.tokens.filter(t => t === 'ui').length; - assert.strictEqual(uiCount, 1, 'Duplicate tokens must be deduplicated'); - }); - - test('non-string input returns { hasUI: false, tokens: [] }', () => { - assert.deepStrictEqual(checkUiPresence(null), { hasUI: false, tokens: [] }); - assert.deepStrictEqual(checkUiPresence(undefined), { hasUI: false, tokens: [] }); - assert.deepStrictEqual(checkUiPresence(42), { hasUI: false, tokens: [] }); - }); - - test('multiple distinct UI tokens on same line are ALL captured', () => { - // "form" and "view" both appear as standalone words on one line. - // Prior exec()-based impl only captured the first match per line. - const result = checkUiPresence('Build a sign-up form with a view controller'); - assert.strictEqual(result.hasUI, true, 'hasUI must be true'); - assert.ok(result.tokens.includes('form'), `Expected "form" in tokens, got: ${JSON.stringify(result.tokens)}`); - assert.ok(result.tokens.includes('view'), `Expected "view" in tokens, got: ${JSON.stringify(result.tokens)}`); - }); -}); diff --git a/tests/bug-3707-locked-worktree-cleanup.test.cjs b/tests/bug-3707-locked-worktree-cleanup.test.cjs deleted file mode 100644 index 9c1d0d802..000000000 --- a/tests/bug-3707-locked-worktree-cleanup.test.cjs +++ /dev/null @@ -1,681 +0,0 @@ -// allow-test-rule: source-text-is-the-product -// Real-filesystem tests for the two failure modes pinned in #3707: -// 1. executeWorktreeWaveCleanupPlan must unlock-then-retry when a worktree is locked. -// 2. reapOrphanWorktrees must reap dead-pid+merged entries and skip live / unmerged / fresh-mtime entries. -// 3. quick.md and execute-phase.md must wire gsd-sdk query worktree.reap-orphans at startup. - -'use strict'; - -const { describe, test, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const os = require('node:os'); -const { execFileSync, spawnSync } = require('node:child_process'); -const { cleanup } = require('./helpers.cjs'); - -const { - executeWorktreeWaveCleanupPlan, - reapOrphanWorktrees, -} = require('../gsd-core/bin/lib/worktree-safety.cjs'); - -// ─── Fixed timestamps for deterministic stale-lock boundary ────────────────── -// -// ADR-456 clock-seam mandate: tests must not read the live clock to compute -// fixture mtimes. The SUT compares `Date.now() - lockMtime.getTime()` against -// REAP_MTIME_GUARD_MS (5 minutes). Because `reapOrphanWorktrees` accepts a -// `deps.mtimeSafe` injection, we can supply fixed Date objects that sit -// unconditionally on the "stale" or "fresh" side of the boundary regardless of -// when the test runs, without touching the real filesystem mtime at all. -// -// STALE_MTIME → Unix epoch (1970-01-01T00:00:00Z). At any point in time -// after that epoch `Date.now() - 0` is orders of magnitude -// larger than any staleness threshold. -// -// FRESH_MTIME → Far-future sentinel (year 9999 + large offset). -// `Date.now() - FRESH_MTIME.getTime()` is always negative, -// which is always < REAP_MTIME_GUARD_MS. -// -// Tests that need stale behaviour pass `{ mtimeSafe: () => STALE_MTIME }` in -// deps. Tests that need fresh behaviour pass `{ mtimeSafe: () => FRESH_MTIME }`. -// No `fs.utimesSync` calls are needed and no live `Date.now()` reads appear in -// fixture setup. - -/** Always older than any staleness threshold. */ -const STALE_MTIME = new Date(0); // 1970-01-01T00:00:00.000Z - -/** Always newer than the current time, so always treated as "fresh". */ -const FRESH_MTIME = new Date(8640000000000000); // max safe JS Date (year ~275760) - -// ─── PID helpers ────────────────────────────────────────────────────────────── - -/** - * Return a PID that is guaranteed to be dead. - * Spawns a short-lived child, captures its PID, waits for it to exit, then - * returns that PID. This is cross-platform and not subject to pid_max races - * (unlike a hardcoded high number such as 999999). - */ -function deadPid() { - // Use the shortest possible no-op: `node -e ""` on all platforms. - const nodeExe = process.execPath; - const result = spawnSync(nodeExe, ['-e', ''], { stdio: 'ignore' }); - if (result.pid == null || result.status === null) { - // Fallback: use a PID above the system max — 2^31-1 always exceeds any - // real OS limit (Linux max: 4194304, macOS max: 99998, Windows: variable). - return 2147483647; - } - return result.pid; -} - -// ─── Git repo helpers ───────────────────────────────────────────────────────── - -function canonicalPath(p) { - try { return fs.realpathSync.native(path.resolve(p)); } catch { return path.resolve(p); } -} - -/** - * Return a canonical (long-form) path for os.tmpdir(). - * On Windows CI, os.tmpdir() often contains 8.3 short-name components - * (e.g. RUNNER~1 instead of runneradmin). When 8.3 names are disabled - * (common in modern CI environments), those short paths are not resolvable - * and fs.realpathSync.native fails with ENOENT. Pre-resolving the base - * ensures every path created under it uses the same long-form representation - * that git stores when given absolute paths. - */ -function resolvedTmpDir() { - try { return fs.realpathSync.native(os.tmpdir()); } catch { return os.tmpdir(); } -} - -function git(args, cwd) { - return execFileSync('git', args, { cwd, stdio: 'pipe', encoding: 'utf8' }); -} - -function initRepo(dir) { - fs.mkdirSync(dir, { recursive: true }); - git(['init'], dir); - git(['config', 'user.email', 'test@test.com'], dir); - git(['config', 'user.name', 'Test'], dir); - git(['config', 'commit.gpgsign', 'false'], dir); - fs.writeFileSync(path.join(dir, 'README.md'), '# Test\n'); - git(['add', '-A'], dir); - git(['commit', '-m', 'initial commit'], dir); - try { git(['branch', '-m', 'master', 'main'], dir); } catch { /* already main */ } -} - -function addWorktree(repoDir, wtDir, branchName) { - git(['worktree', 'add', wtDir, '-b', branchName], repoDir); -} - -function commitInWorktree(wtDir, filename) { - const fname = filename || 'work.txt'; - fs.writeFileSync(path.join(wtDir, fname), 'content\n'); - git(['add', '-A'], wtDir); - git(['commit', '-m', `work in ${path.basename(wtDir)}`], wtDir); -} - -function mergeIntoMain(repoDir, branchName) { - git(['merge', branchName, '--no-ff', '-m', `merge ${branchName}`], repoDir); -} - -function worktreeMeta(repoDir, wtDir) { - // Return the .git/worktrees// directory for a given linked worktree - const worktrees = git(['worktree', 'list', '--porcelain'], repoDir); - const canonical = canonicalPath(wtDir); - // Normalize CRLF → LF before splitting (git on Windows may emit CRLF). - const normalized = worktrees.replace(/\r\n/g, '\n'); - const blocks = normalized.split('\n\n').filter(Boolean); - for (const block of blocks) { - const lines = block.split('\n'); - const wtLine = lines.find((l) => l.startsWith('worktree ')); - if (!wtLine) continue; - const wtPath = wtLine.slice('worktree '.length).trim(); - if (canonicalPath(wtPath) !== canonical) continue; - const gitCommonDir = git(['rev-parse', '--git-common-dir'], repoDir).trim(); - const worktreesDir = path.join(path.resolve(repoDir, gitCommonDir), 'worktrees'); - if (!fs.existsSync(worktreesDir)) continue; - for (const entry of fs.readdirSync(worktreesDir)) { - const gitdirFile = path.join(worktreesDir, entry, 'gitdir'); - if (!fs.existsSync(gitdirFile)) continue; - const gitdirContent = fs.readFileSync(gitdirFile, 'utf8').trim(); - const resolvedWtRoot = path.resolve(worktreesDir, entry, gitdirContent).replace(/[/\\]\.git$/, ''); - if (canonicalPath(resolvedWtRoot) === canonical) { - return path.join(worktreesDir, entry); - } - } - } - throw new Error(`Cannot find .git/worktrees/ for worktree at ${wtDir}`); -} - -function listedWorktreePaths(repoDir) { - const out = git(['worktree', 'list', '--porcelain'], repoDir); - return new Set( - out.split('\n').filter((l) => l.startsWith('worktree ')).map((l) => canonicalPath(l.slice('worktree '.length).trim())) - ); -} - -// ─── Suite 1: executeWorktreeWaveCleanupPlan — unlock-and-retry ─────────────── - -describe('bug-3707: executeWorktreeWaveCleanupPlan unlocks and retries on locked worktree', () => { - let tmpBase; - - beforeEach(() => { - tmpBase = fs.mkdtempSync(path.join(resolvedTmpDir(), 'gsd-3707-cleanup-')); - }); - - afterEach(() => { - cleanup(tmpBase); - }); - - test('removes a locked worktree after unlock-retry (real-fs)', () => { - const repoDir = path.join(tmpBase, 'repo'); - const wtDir = path.join(tmpBase, 'wt-locked'); - const branchName = 'worktree-agent-test1'; - - initRepo(repoDir); - addWorktree(repoDir, wtDir, branchName); - commitInWorktree(wtDir); - mergeIntoMain(repoDir, branchName); - - // Simulate Claude Code's lock: write a .git/worktrees//locked file - const metaDir = worktreeMeta(repoDir, wtDir); - const lockedFile = path.join(metaDir, 'locked'); - fs.writeFileSync(lockedFile, 'Locked by claude-code agent-test1'); - - assert.ok(fs.existsSync(lockedFile), 'lock file should exist before test'); - - const baseCommit = git(['merge-base', 'HEAD', branchName], repoDir).trim(); - - const plan = { - ok: true, - repoRoot: repoDir, - action: 'cleanup_wave', - discovery: 'manifest', - entries: [{ - agent_id: 'test1', - worktree_path: wtDir, - branch: branchName, - expected_base: baseCommit, - }], - }; - - const result = executeWorktreeWaveCleanupPlan(plan); - - assert.equal(result.ok, true, `cleanup should succeed, got: ${JSON.stringify(result)}`); - assert.equal(result.entries[0].status, 'merged_removed'); - assert.ok(!fs.existsSync(wtDir), 'worktree directory should be gone after cleanup'); - assert.ok(!listedWorktreePaths(repoDir).has(canonicalPath(wtDir)), 'git worktree list should not include removed worktree'); - }); - - test('cleanup succeeds without a lock file present (no regression)', () => { - const repoDir = path.join(tmpBase, 'repo2'); - const wtDir = path.join(tmpBase, 'wt-unlocked'); - const branchName = 'worktree-agent-test2'; - - initRepo(repoDir); - addWorktree(repoDir, wtDir, branchName); - commitInWorktree(wtDir, 'unlocked.txt'); - mergeIntoMain(repoDir, branchName); - - const baseCommit = git(['merge-base', 'HEAD', branchName], repoDir).trim(); - - const plan = { - ok: true, - repoRoot: repoDir, - action: 'cleanup_wave', - discovery: 'manifest', - entries: [{ - agent_id: 'test2', - worktree_path: wtDir, - branch: branchName, - expected_base: baseCommit, - }], - }; - - const result = executeWorktreeWaveCleanupPlan(plan); - - assert.equal(result.ok, true, `unlocked cleanup should succeed: ${JSON.stringify(result)}`); - assert.equal(result.entries[0].status, 'merged_removed'); - assert.ok(!fs.existsSync(wtDir), 'worktree directory should be gone'); - }); -}); - -// ─── Suite 2: reapOrphanWorktrees ───────────────────────────────────────────── - -describe('bug-3707: reapOrphanWorktrees', () => { - let tmpBase; - - beforeEach(() => { - tmpBase = fs.mkdtempSync(path.join(resolvedTmpDir(), 'gsd-3707-reap-')); - }); - - afterEach(() => { - cleanup(tmpBase); - }); - - // ── Dead PID + merged branch → reap ──────────────────────────────────────── - test('reaps a worktree whose pid is dead and branch is merged into main', () => { - const repoDir = path.join(tmpBase, 'repo'); - const wtDir = path.join(tmpBase, 'wt-dead-merged'); - const branchName = 'worktree-agent-dead-merged'; - - initRepo(repoDir); - addWorktree(repoDir, wtDir, branchName); - commitInWorktree(wtDir); - mergeIntoMain(repoDir, branchName); - - // Write a lock file with a definitely-dead PID. Use the deadPid() helper - // which spawns and reaps a real child process — avoids pid_max flakiness - // on Linux systems where 999999 could be a live PID. - const metaDir = worktreeMeta(repoDir, wtDir); - const lockedFile = path.join(metaDir, 'locked'); - fs.writeFileSync(lockedFile, String(deadPid())); - - // Inject a fixed stale mtime (STALE_MTIME = Unix epoch) so the staleness - // check is deterministic and does not depend on the real clock or utimesSync. - // STALE_MTIME is always older than REAP_MTIME_GUARD_MS (5 min) regardless - // of when this test runs. No fs.utimesSync call is needed. - - // Pre-compute canonical path BEFORE reaping — the directory will be gone - // afterward, so fs.realpathSync.native will fail and canonicalPath falls - // back to path.resolve (non-symlink-resolved). On macOS CI, git internally - // resolves /var/folders → /private/var/folders when writing the gitdir file, - // so r.path uses the real path while wtDir uses the symlink form. Computing - // canonical before removal ensures we compare the resolved forms. - const wtDirCanonical = canonicalPath(wtDir); - - const result = reapOrphanWorktrees(repoDir, { mtimeSafe: () => STALE_MTIME }); - - assert.ok(Array.isArray(result), 'reapOrphanWorktrees should return an array'); - const reaped = result.find((r) => canonicalPath(r.path) === wtDirCanonical); - assert.ok(reaped, `worktree ${wtDir} should appear in reaped list`); - assert.equal(reaped.status, 'reaped'); - assert.ok(!fs.existsSync(wtDir), 'worktree directory should be removed'); - assert.ok(!listedWorktreePaths(repoDir).has(wtDirCanonical), 'git worktree list should not show reaped worktree'); - }); - - // ── Live PID → skip ──────────────────────────────────────────────────────── - test('skips a worktree whose pid is alive', () => { - const repoDir = path.join(tmpBase, 'repo2'); - const wtDir = path.join(tmpBase, 'wt-live-pid'); - const branchName = 'worktree-agent-live-pid'; - - initRepo(repoDir); - addWorktree(repoDir, wtDir, branchName); - commitInWorktree(wtDir); - mergeIntoMain(repoDir, branchName); - - // Write current process PID as the lock owner - const metaDir = worktreeMeta(repoDir, wtDir); - const lockedFile = path.join(metaDir, 'locked'); - fs.writeFileSync(lockedFile, String(process.pid)); - - // Inject STALE_MTIME so the staleness guard passes deterministically, - // ensuring the live-PID check is the only reason the entry is skipped. - const result = reapOrphanWorktrees(repoDir, { mtimeSafe: () => STALE_MTIME }); - - const skipped = result.find((r) => canonicalPath(r.path) === canonicalPath(wtDir)); - if (skipped) { - assert.notEqual(skipped.status, 'reaped', 'live-pid worktree must not be reaped'); - } - assert.ok(fs.existsSync(wtDir), 'worktree directory must still exist for live-pid worktree'); - }); - - // ── Dead PID + unmerged branch → skip (data loss guard) ──────────────────── - test('skips a worktree whose branch has unmerged commits even with dead pid', () => { - const repoDir = path.join(tmpBase, 'repo3'); - const wtDir = path.join(tmpBase, 'wt-unmerged'); - const branchName = 'worktree-agent-unmerged'; - - initRepo(repoDir); - addWorktree(repoDir, wtDir, branchName); - commitInWorktree(wtDir, 'unmerged.txt'); - // NOTE: intentionally NOT merging the branch into main - - const metaDir = worktreeMeta(repoDir, wtDir); - const lockedFile = path.join(metaDir, 'locked'); - fs.writeFileSync(lockedFile, String(deadPid())); - - // Inject STALE_MTIME so the staleness guard passes deterministically, - // ensuring the unmerged-branch check is the only reason the entry is skipped. - const result = reapOrphanWorktrees(repoDir, { mtimeSafe: () => STALE_MTIME }); - - const entry = result.find((r) => canonicalPath(r.path) === canonicalPath(wtDir)); - if (entry) { - assert.notEqual(entry.status, 'reaped', 'unmerged worktree must not be reaped (data loss guard)'); - } - assert.ok(fs.existsSync(wtDir), 'unmerged worktree directory must still exist'); - }); - - // ── Dead PID + merged + fresh mtime → skip (race guard) ─────────────────── - test('skips a locked worktree with fresh mtime even when pid is dead and branch is merged', () => { - const repoDir = path.join(tmpBase, 'repo4'); - const wtDir = path.join(tmpBase, 'wt-fresh-lock'); - const branchName = 'worktree-agent-fresh-lock'; - - initRepo(repoDir); - addWorktree(repoDir, wtDir, branchName); - commitInWorktree(wtDir, 'fresh.txt'); - mergeIntoMain(repoDir, branchName); - - const metaDir = worktreeMeta(repoDir, wtDir); - const lockedFile = path.join(metaDir, 'locked'); - fs.writeFileSync(lockedFile, String(deadPid())); - - // Inject FRESH_MTIME (far future) so the staleness boundary is crossed - // deterministically: Date.now() - FRESH_MTIME.getTime() is always negative, - // which is always less than REAP_MTIME_GUARD_MS. No utimesSync needed. - // Previously, this test relied on the file being just-created (real clock - // within 5 minutes) which is fragile on heavily-loaded CI hosts. - const result = reapOrphanWorktrees(repoDir, { mtimeSafe: () => FRESH_MTIME }); - - const entry = result.find((r) => canonicalPath(r.path) === canonicalPath(wtDir)); - if (entry) { - assert.notEqual(entry.status, 'reaped', 'fresh-mtime worktree must not be reaped (race guard)'); - } - assert.ok(fs.existsSync(wtDir), 'fresh-lock worktree directory must still exist'); - }); - - // ── Double invocation → idempotent ───────────────────────────────────────── - test('is idempotent: second invocation is a no-op', () => { - const repoDir = path.join(tmpBase, 'repo5'); - const wtDir = path.join(tmpBase, 'wt-idempotent'); - const branchName = 'worktree-agent-idempotent'; - - initRepo(repoDir); - addWorktree(repoDir, wtDir, branchName); - commitInWorktree(wtDir, 'idempotent.txt'); - mergeIntoMain(repoDir, branchName); - - const metaDir = worktreeMeta(repoDir, wtDir); - const lockedFile = path.join(metaDir, 'locked'); - fs.writeFileSync(lockedFile, String(deadPid())); - - // Inject STALE_MTIME so the staleness guard is deterministically satisfied. - const staleDeps = { mtimeSafe: () => STALE_MTIME }; - - const result1 = reapOrphanWorktrees(repoDir, staleDeps); - const reaped1 = result1.filter((r) => r.status === 'reaped'); - assert.equal(reaped1.length, 1, 'first invocation should reap exactly one entry'); - - // Second invocation: nothing left to reap - const result2 = reapOrphanWorktrees(repoDir, staleDeps); - const reaped2 = result2.filter((r) => r.status === 'reaped'); - assert.equal(reaped2.length, 0, 'second invocation should reap nothing (idempotent)'); - }); -}); - -// ─── Suite 3: Structural — startup sweep wiring ─────────────────────────────── - -describe('bug-3707: startup orphan sweep is wired into workflow entry points', () => { - const QUICK_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'quick.md'); - const EXECUTE_PHASE_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md'); - - test('quick.md calls worktree.reap-orphans at startup when USE_WORKTREES is not false', () => { - const content = fs.readFileSync(QUICK_PATH, 'utf8'); - assert.ok( - content.includes('worktree.reap-orphans'), - 'quick.md must call gsd-sdk query worktree.reap-orphans at startup' - ); - // Must be guarded by USE_WORKTREES check - assert.ok( - /USE_WORKTREES.*!=.*false[\s\S]{0,200}worktree\.reap-orphans/m.test(content) || - /worktree\.reap-orphans[\s\S]{0,200}USE_WORKTREES.*!=.*false/m.test(content), - 'quick.md startup sweep must be guarded by USE_WORKTREES != false' - ); - }); - - test('execute-phase.md calls worktree.reap-orphans at startup when USE_WORKTREES is not false', () => { - const content = fs.readFileSync(EXECUTE_PHASE_PATH, 'utf8'); - assert.ok( - content.includes('worktree.reap-orphans'), - 'execute-phase.md must call gsd-sdk query worktree.reap-orphans at startup' - ); - assert.ok( - /USE_WORKTREES.*!=.*false[\s\S]{0,200}worktree\.reap-orphans/m.test(content) || - /worktree\.reap-orphans[\s\S]{0,200}USE_WORKTREES.*!=.*false/m.test(content), - 'execute-phase.md startup sweep must be guarded by USE_WORKTREES != false' - ); - }); - - test('worktree-safety module exports reapOrphanWorktrees', () => { - const mod = require('../gsd-core/bin/lib/worktree-safety.cjs'); - assert.strictEqual(typeof mod.reapOrphanWorktrees, 'function'); - }); - - test('worktree-safety module exports cmdWorktreeReapOrphans', () => { - const mod = require('../gsd-core/bin/lib/worktree-safety.cjs'); - assert.strictEqual(typeof mod.cmdWorktreeReapOrphans, 'function'); - }); -}); - -// ─── Suite 3b: nowMs clock-injection BOUNDARY tests (#1191) ────────────────── -// -// These tests inject both `nowMs` and `mtimeSafe` so no real clock is read. -// The staleness guard is: nowMs - lockMtime.getTime() < reapMtimeGuardMs. -// -// REAP_MTIME_GUARD_MS = 5 * 60 * 1000 = 300000 ms. -// -// We use a fixed lockMtime of 1000 ms (epoch+1s) and compute nowMs values that -// are exactly 1 ms inside (age = 299999 ms < 300000) vs exactly 1 ms outside -// (age = 300000 ms, NOT < 300000) the guard boundary. - -const KNOWN_REAP_MTIME_GUARD_MS = 5 * 60 * 1000; // 300000 ms — mirrors SUT constant -const FIXED_LOCK_MTIME_MS = 1000; // 1970-01-01T00:00:01.000Z -const FIXED_LOCK_DATE = new Date(FIXED_LOCK_MTIME_MS); - -describe('bug-3707: reapOrphanWorktrees — nowMs clock-injection BOUNDARY tests (#1191)', () => { - let tmpBase; - - beforeEach(() => { - tmpBase = fs.mkdtempSync(path.join(resolvedTmpDir(), 'gsd-3707-nowms-')); - }); - - afterEach(() => { - cleanup(tmpBase); - }); - - // ── Just-inside boundary: age = guard - 1 → skip (lock_too_fresh) ─────────── - test('skips when injected nowMs places lock age just inside guard (age < guard)', () => { - // age = nowMs - FIXED_LOCK_MTIME_MS = (FIXED_LOCK_MTIME_MS + KNOWN_REAP_MTIME_GUARD_MS - 1) - FIXED_LOCK_MTIME_MS - // = KNOWN_REAP_MTIME_GUARD_MS - 1 = 299999 ms → 299999 < 300000 → SKIP - const nowMs = FIXED_LOCK_MTIME_MS + KNOWN_REAP_MTIME_GUARD_MS - 1; - - const repoDir = path.join(tmpBase, 'repo-inside'); - const wtDir = path.join(tmpBase, 'wt-inside-guard'); - const branchName = 'worktree-boundary-inside'; - - initRepo(repoDir); - addWorktree(repoDir, wtDir, branchName); - commitInWorktree(wtDir, 'inside.txt'); - mergeIntoMain(repoDir, branchName); - - const metaDir = worktreeMeta(repoDir, wtDir); - const lockedFile = path.join(metaDir, 'locked'); - fs.writeFileSync(lockedFile, String(deadPid())); - - // Inject both nowMs and mtimeSafe — no real clock is read - const result = reapOrphanWorktrees(repoDir, { - nowMs, - mtimeSafe: () => FIXED_LOCK_DATE, - }); - - assert.ok(Array.isArray(result), 'must return an array'); - const entry = result.find((r) => canonicalPath(r.path) === canonicalPath(wtDir)); - assert.ok(entry, 'worktree must appear in results'); - assert.equal(entry.status, 'skipped', `status must be skipped when age=${nowMs - FIXED_LOCK_MTIME_MS}ms < guard=${KNOWN_REAP_MTIME_GUARD_MS}ms`); - assert.equal(entry.reason, 'lock_too_fresh', 'reason must be lock_too_fresh'); - assert.ok(fs.existsSync(wtDir), 'worktree directory must still exist (not reaped)'); - }); - - // ── Just-outside boundary: age = guard → reap (age NOT < guard) ───────────── - test('reaps when injected nowMs places lock age exactly at guard boundary (age === guard)', () => { - // age = nowMs - FIXED_LOCK_MTIME_MS = (FIXED_LOCK_MTIME_MS + KNOWN_REAP_MTIME_GUARD_MS) - FIXED_LOCK_MTIME_MS - // = KNOWN_REAP_MTIME_GUARD_MS = 300000 ms → 300000 NOT < 300000 → PROCEED TO REAP - const nowMs = FIXED_LOCK_MTIME_MS + KNOWN_REAP_MTIME_GUARD_MS; - - const repoDir = path.join(tmpBase, 'repo-outside'); - const wtDir = path.join(tmpBase, 'wt-outside-guard'); - const branchName = 'worktree-boundary-outside'; - - initRepo(repoDir); - addWorktree(repoDir, wtDir, branchName); - commitInWorktree(wtDir, 'outside.txt'); - mergeIntoMain(repoDir, branchName); - - const metaDir = worktreeMeta(repoDir, wtDir); - const lockedFile = path.join(metaDir, 'locked'); - // Use deadPid() — a truly dead process — so PID check passes and reap proceeds - fs.writeFileSync(lockedFile, String(deadPid())); - - const wtDirCanonical = canonicalPath(wtDir); - - // Inject both nowMs and mtimeSafe — no real clock is read - const result = reapOrphanWorktrees(repoDir, { - nowMs, - mtimeSafe: () => FIXED_LOCK_DATE, - }); - - assert.ok(Array.isArray(result), 'must return an array'); - const entry = result.find((r) => canonicalPath(r.path) === wtDirCanonical); - assert.ok(entry, 'worktree must appear in results'); - assert.equal(entry.status, 'reaped', `status must be reaped when age=${nowMs - FIXED_LOCK_MTIME_MS}ms >= guard=${KNOWN_REAP_MTIME_GUARD_MS}ms`); - assert.ok(!fs.existsSync(wtDir), 'worktree directory must be removed after reaping'); - }); -}); - -// ─── Suite 4: Adversarial gap tests ────────────────────────────────────────── - -describe('bug-3707: reapOrphanWorktrees — adversarial edge cases', () => { - let tmpBase; - - beforeEach(() => { - tmpBase = fs.mkdtempSync(path.join(resolvedTmpDir(), 'gsd-3707-adv-')); - }); - - afterEach(() => { - cleanup(tmpBase); - }); - - // ── Gap 1: Non-numeric lock content (real Claude Code format) → ALIVE (fail-closed) ── - test('does NOT reap a worktree whose lock contains non-numeric Claude Code content', () => { - // Claude Code writes "Locked by claude-code agent-" as the lock content. - // This is non-numeric and MUST be treated as ALIVE (fail-closed) — we cannot - // confirm the owner is dead, so reaping would risk data loss. - const repoDir = path.join(tmpBase, 'repo'); - const wtDir = path.join(tmpBase, 'wt-claude-lock'); - const branchName = 'worktree-agent-claude-lock'; - - initRepo(repoDir); - addWorktree(repoDir, wtDir, branchName); - commitInWorktree(wtDir, 'claude-work.txt'); - mergeIntoMain(repoDir, branchName); - - const metaDir = worktreeMeta(repoDir, wtDir); - const lockedFile = path.join(metaDir, 'locked'); - // Write the real Claude Code lock format (non-numeric) - fs.writeFileSync(lockedFile, 'Locked by claude-code agent-a1b2c3d4e5f6'); - - // Inject STALE_MTIME so the staleness guard passes deterministically, - // ensuring the non-numeric content check is the only reason the entry is skipped. - const result = reapOrphanWorktrees(repoDir, { mtimeSafe: () => STALE_MTIME }); - - const entry = result.find((r) => canonicalPath(r.path) === canonicalPath(wtDir)); - if (entry) { - assert.notEqual( - entry.status, - 'reaped', - 'non-numeric Claude Code lock must NOT be reaped (fail-closed: owner unknown)' - ); - assert.equal(entry.status, 'skipped', 'non-numeric lock entry should have status=skipped'); - assert.equal(entry.reason, 'lock_owner_unknown', 'reason must be lock_owner_unknown'); - } - assert.ok(fs.existsSync(wtDir), 'worktree with Claude Code lock must NOT be removed'); - }); - - // ── Gap 2: EPERM in defaultIsPidAlive → ALIVE (fail-closed) ───────────────── - test('treats EPERM from isPidAlive as ALIVE (fail-closed)', () => { - // On Windows, signalling cross-user processes throws EPERM, not ESRCH. - // The reaper must treat EPERM as ALIVE to avoid false reaping. - const repoDir = path.join(tmpBase, 'repo2'); - const wtDir = path.join(tmpBase, 'wt-eperm'); - const branchName = 'worktree-agent-eperm'; - - initRepo(repoDir); - addWorktree(repoDir, wtDir, branchName); - commitInWorktree(wtDir, 'eperm-work.txt'); - mergeIntoMain(repoDir, branchName); - - const metaDir = worktreeMeta(repoDir, wtDir); - const lockedFile = path.join(metaDir, 'locked'); - fs.writeFileSync(lockedFile, String(deadPid())); - - // Inject an isPidAlive that always throws EPERM — simulates Windows cross-user scenario. - // Also inject STALE_MTIME so the staleness guard is deterministically satisfied. - const epermIsPidAlive = (_pid) => { - const err = new Error('EPERM: operation not permitted'); - err.code = 'EPERM'; - throw err; - }; - - const result = reapOrphanWorktrees(repoDir, { - isPidAlive: epermIsPidAlive, - mtimeSafe: () => STALE_MTIME, - }); - - const entry = result.find((r) => canonicalPath(r.path) === canonicalPath(wtDir)); - if (entry) { - assert.notEqual(entry.status, 'reaped', 'EPERM from isPidAlive must be treated as ALIVE — must not reap'); - } - assert.ok(fs.existsSync(wtDir), 'worktree must still exist when isPidAlive throws EPERM'); - }); - - // ── Gap 3: Non-main/master default branch via init.defaultBranch ───────────── - test('uses init.defaultBranch config when default branch is not main or master', () => { - // Repos configured with init.defaultBranch=trunk (or dev, etc.) were - // previously unreachable by the main/master fallback, causing the reaper - // to bail out and silently skip all orphan detection. - const repoDir = path.join(tmpBase, 'repo3'); - const wtDir = path.join(tmpBase, 'wt-trunk-default'); - const branchName = 'worktree-agent-trunk-merged'; - - // Create a repo whose default branch is 'trunk' - fs.mkdirSync(repoDir, { recursive: true }); - git(['init'], repoDir); - git(['config', 'user.email', 'test@test.com'], repoDir); - git(['config', 'user.name', 'Test'], repoDir); - git(['config', 'commit.gpgsign', 'false'], repoDir); - // Set init.defaultBranch to 'trunk' so the reaper discovers it - git(['config', 'init.defaultBranch', 'trunk'], repoDir); - fs.writeFileSync(path.join(repoDir, 'README.md'), '# Trunk Test\n'); - git(['add', '-A'], repoDir); - git(['commit', '-m', 'initial commit'], repoDir); - // Rename to trunk (may fail if already trunk) - try { git(['branch', '-m', 'master', 'trunk'], repoDir); } catch { /* already trunk or main */ } - try { git(['branch', '-m', 'main', 'trunk'], repoDir); } catch { /* already trunk */ } - - addWorktree(repoDir, wtDir, branchName); - commitInWorktree(wtDir, 'trunk-work.txt'); - // Merge branch into trunk - git(['merge', branchName, '--no-ff', '-m', 'merge into trunk'], repoDir); - - const metaDir = worktreeMeta(repoDir, wtDir); - const lockedFile = path.join(metaDir, 'locked'); - fs.writeFileSync(lockedFile, String(deadPid())); - - // Inject STALE_MTIME so the staleness guard is deterministically satisfied. - // Pre-compute canonical before reaping (symlink resolution may fail post-removal). - const wtDirCanonical = canonicalPath(wtDir); - - const result = reapOrphanWorktrees(repoDir, { mtimeSafe: () => STALE_MTIME }); - - // The reaper must either reap the worktree (using trunk as the default branch) - // OR skip it for a safe reason — it must NOT return an empty result (which - // would mean it bailed out entirely, silently skipping all orphan detection). - assert.ok(Array.isArray(result), 'reapOrphanWorktrees must return an array'); - assert.ok(result.length > 0, 'reaper must not bail out entirely for trunk-default repos — must inspect the worktree'); - const entry = result.find((r) => canonicalPath(r.path) === wtDirCanonical); - assert.ok(entry, 'worktree must appear in results (reaped or skipped with reason)'); - // The branch IS merged into trunk, and the PID is dead, so it should be reaped. - assert.equal(entry.status, 'reaped', 'worktree with dead pid merged into trunk must be reaped'); - }); -}); diff --git a/tests/bug-3727-code-review-fix-flag-dispatch.test.cjs b/tests/bug-3727-code-review-fix-flag-dispatch.test.cjs deleted file mode 100644 index bb4ff2406..000000000 --- a/tests/bug-3727-code-review-fix-flag-dispatch.test.cjs +++ /dev/null @@ -1,164 +0,0 @@ -// allow-test-rule: source-text-is-the-product -// The workflow .md IS the product: its text is loaded and interpreted at -// runtime by the agent host. Structural assertions (step existence, flag -// references in the initialize step) verify the deployed dispatch contract. -// Pure-function assertions on parseCodeReviewFlags / resolveCodeReviewWorkflow -// are IR-level (not raw-text-match) per CONTRIBUTING.md L554. - -/** - * Regression tests for #3727 — /gsd-code-review N --fix silently no-ops. - * - * Root cause: code-review.md `initialize` step parses only --depth and - * --files flags. --fix, --all, and --auto are never parsed, so no dispatch - * to code-review-fix.md ever occurs. - * - * Fix: add parseCodeReviewFlags() to code-review-flags.cjs (typed IR), wire - * it into the workflow initialize step, and add a dispatch_fix step that - * delegates to code-review-fix.md when flags.fix is true. - */ - -'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 ROOT = path.resolve(__dirname, '..'); -const FLAGS_LIB = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'code-review-flags.cjs'); -const WORKFLOW_PATH = path.join(ROOT, 'gsd-core', 'workflows', 'code-review.md'); - -// --------------------------------------------------------------------------- -// Stage 2 seam: typed IR from parseCodeReviewFlags and resolveCodeReviewWorkflow -// These functions are the production code under test — not text-matching on -// the .md source. -// --------------------------------------------------------------------------- - -const { parseCodeReviewFlags, resolveCodeReviewWorkflow } = require(FLAGS_LIB); - -describe('#3727 — parseCodeReviewFlags: typed IR for code-review argv', () => { - test('code-review workflow parses --fix flag and dispatches gsd-code-fixer', () => { - // This is the invariant being violated: --fix must be captured into the IR - // and must route to the fixer workflow, not be silently dropped. - const flags = parseCodeReviewFlags(['2', '--fix']); - assert.strictEqual(flags.fix, true, - '--fix must set flags.fix = true'); - const workflow = resolveCodeReviewWorkflow(flags); - assert.strictEqual(workflow, 'code-review-fix.md', - '--fix must dispatch to code-review-fix.md, not code-review.md'); - }); - - test('--fix absent: dispatch stays on code-review.md (review-only)', () => { - // Counter-test (Stage 5 contract 6): omitting --fix must NOT dispatch fixer. - const flags = parseCodeReviewFlags(['2']); - assert.strictEqual(flags.fix, false, - 'Without --fix, flags.fix must be false'); - const workflow = resolveCodeReviewWorkflow(flags); - assert.strictEqual(workflow, 'code-review.md', - 'Without --fix, dispatch must stay on code-review.md'); - }); - - test('--all implies --fix', () => { - const flags = parseCodeReviewFlags(['3', '--all']); - assert.strictEqual(flags.fix, true, - '--all must imply flags.fix = true'); - assert.strictEqual(flags.all, true, - '--all must set flags.all = true'); - const workflow = resolveCodeReviewWorkflow(flags); - assert.strictEqual(workflow, 'code-review-fix.md', - '--all must dispatch to code-review-fix.md'); - }); - - test('--auto implies --fix', () => { - const flags = parseCodeReviewFlags(['3', '--auto']); - assert.strictEqual(flags.fix, true, - '--auto must imply flags.fix = true'); - assert.strictEqual(flags.auto, true, - '--auto must set flags.auto = true'); - const workflow = resolveCodeReviewWorkflow(flags); - assert.strictEqual(workflow, 'code-review-fix.md', - '--auto must dispatch to code-review-fix.md'); - }); - - test('--fix --all --auto all set simultaneously', () => { - const flags = parseCodeReviewFlags(['1', '--fix', '--all', '--auto']); - assert.strictEqual(flags.fix, true); - assert.strictEqual(flags.all, true); - assert.strictEqual(flags.auto, true); - assert.strictEqual(resolveCodeReviewWorkflow(flags), 'code-review-fix.md'); - }); - - test('--depth flag is still parsed alongside --fix (no regression)', () => { - const flags = parseCodeReviewFlags(['2', '--depth=deep', '--fix']); - assert.strictEqual(flags.fix, true); - assert.strictEqual(flags.depth, 'deep'); - assert.strictEqual(resolveCodeReviewWorkflow(flags), 'code-review-fix.md'); - }); - - test('--files flag is still parsed alongside --fix (no regression)', () => { - const flags = parseCodeReviewFlags(['2', '--files=src/foo.ts,src/bar.ts', '--fix']); - assert.strictEqual(flags.fix, true); - assert.strictEqual(flags.files, 'src/foo.ts,src/bar.ts'); - }); - - test('empty argv returns all-false IR', () => { - const flags = parseCodeReviewFlags([]); - assert.strictEqual(flags.fix, false); - assert.strictEqual(flags.all, false); - assert.strictEqual(flags.auto, false); - assert.strictEqual(flags.depth, ''); - assert.strictEqual(flags.files, ''); - }); -}); - -// --------------------------------------------------------------------------- -// Docs-parity: the workflow .md must contain the dispatch_fix step and -// reference code-review-flags.cjs in the initialize step. -// These are structural invariants on the deployed product text. -// Source-text-is-the-product exemption applies (see file header). -// --------------------------------------------------------------------------- - -describe('#3727 — code-review.md structural dispatch contract', () => { - const src = fs.readFileSync(WORKFLOW_PATH, 'utf8'); - - test('initialize step references code-review-flags.cjs for flag parsing', () => { - const initStart = src.indexOf(''); - const initEnd = src.indexOf('', initStart); - assert.ok(initStart !== -1, 'workflow must have an initialize step'); - const initSection = src.slice(initStart, initEnd); - assert.ok( - initSection.includes('code-review-flags.cjs'), - 'initialize step must reference code-review-flags.cjs to parse --fix/--all/--auto flags' - ); - }); - - test('workflow has a step', () => { - assert.ok( - src.includes(''), - 'code-review.md must have a dispatch_fix step (missing = --fix is silently no-op)' - ); - }); - - test('dispatch_fix step delegates to code-review-fix.md when fix flag is true', () => { - const stepStart = src.indexOf(''); - const stepEnd = src.indexOf('', stepStart); - assert.ok(stepStart !== -1, 'dispatch_fix step must exist'); - const stepSection = src.slice(stepStart, stepEnd); - assert.ok( - stepSection.includes('code-review-fix.md'), - 'dispatch_fix step must reference code-review-fix.md workflow' - ); - }); - - test('dispatch_fix step references gsd-code-fixer agent (via code-review-fix.md chain)', () => { - // The dispatch step must show that fixing will occur, either directly or - // by loading code-review-fix.md (which in turn spawns gsd-code-fixer). - const stepStart = src.indexOf(''); - const stepEnd = src.indexOf('', stepStart); - const stepSection = src.slice(stepStart, stepEnd); - assert.ok( - stepSection.includes('gsd-code-fixer') || stepSection.includes('code-review-fix.md'), - 'dispatch_fix step must reference gsd-code-fixer agent or code-review-fix.md workflow' - ); - }); -}); diff --git a/tests/bug-3739-gap-checker-padded-prefix-context.test.cjs b/tests/bug-3739-gap-checker-padded-prefix-context.test.cjs deleted file mode 100644 index c6b078f51..000000000 --- a/tests/bug-3739-gap-checker-padded-prefix-context.test.cjs +++ /dev/null @@ -1,212 +0,0 @@ -/** - * Bug #3739: gap-analysis silently skips CONTEXT.md decisions when the file - * uses the padded-prefix convention (e.g. 01-CONTEXT.md, 02.1-CONTEXT.md). - * - * Verifies: - * 1. Padded-prefix CONTEXT.md (NN-CONTEXT.md) decisions ARE included in the - * gap report — was silently skipped before the fix. - * 2. Decisions from padded-prefix CONTEXT.md ARE checked for coverage. - * 3. Bare CONTEXT.md still works — no regression on the existing path. - * 4. A padded-prefix decision that is NOT covered in the plan is surfaced - * as "Not covered" (not silently dropped from the report). - * 5. planning-workspace.cjs findContextMdIn() helper returns the right - * filename for both bare and padded forms (unit test for the extractor). - */ - -'use strict'; - -const { describe, test, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); -const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); - -describe('bug #3739 — gap-analysis padded-prefix CONTEXT.md', () => { - let tmpDir; - let phaseDir; - - function writeContextAs(filename, decisions) { - const dLines = decisions.map(d => `- **${d.id}:** ${d.text}`).join('\n'); - fs.writeFileSync( - path.join(phaseDir, filename), - `# Phase Context\n\n\n## Implementation Decisions\n\n${dLines}\n\n` - ); - } - - function writePlan(name, body) { - fs.writeFileSync(path.join(phaseDir, `${name}-PLAN.md`), body); - } - - function ensureConfig() { - const r = runGsdTools('config-ensure-section', tmpDir); - assert.ok(r.success, `config-ensure-section failed: ${r.error}`); - } - - beforeEach(() => { - tmpDir = createTempProject(); - phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test'); - fs.mkdirSync(phaseDir, { recursive: true }); - ensureConfig(); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - // ── Test 1: padded-prefix decisions appear in the gap report ───────────── - - test('decisions from padded-prefix CONTEXT.md (01-CONTEXT.md) appear in gap report', () => { - writeContextAs('01-CONTEXT.md', [ - { id: 'D-01', text: 'Use library X' }, - { id: 'D-02', text: 'Fail loud on unknown input' }, - ]); - writePlan('01', '# Plan\n\nImplements D-01 and D-02.\n'); - - const r = runGsdTools(['gap-analysis', '--phase-dir', phaseDir], tmpDir); - assert.ok(r.success, `gap-analysis failed: ${r.error}`); - const out = JSON.parse(r.output); - - const d01 = out.rows.find(x => x.item === 'D-01'); - const d02 = out.rows.find(x => x.item === 'D-02'); - - assert.ok(d01, 'D-01 row must appear in gap report when CONTEXT.md uses padded-prefix 01-CONTEXT.md'); - assert.ok(d02, 'D-02 row must appear in gap report when CONTEXT.md uses padded-prefix 01-CONTEXT.md'); - assert.strictEqual(d01.source, 'CONTEXT.md', 'source label must be CONTEXT.md'); - assert.strictEqual(d01.status, 'Covered', 'D-01 is mentioned in plan — must be Covered'); - assert.strictEqual(d02.status, 'Covered', 'D-02 is mentioned in plan — must be Covered'); - }); - - // ── Test 2: uncovered padded-prefix decision surfaces as Not covered ────── - - test('uncovered decision from padded-prefix CONTEXT.md surfaces as Not covered', () => { - writeContextAs('01-CONTEXT.md', [ - { id: 'D-01', text: 'Use library X' }, - ]); - writePlan('01', '# Plan\n\nUnrelated work, no mention of any D-NN.\n'); - - const r = runGsdTools(['gap-analysis', '--phase-dir', phaseDir], tmpDir); - assert.ok(r.success, `gap-analysis failed: ${r.error}`); - const out = JSON.parse(r.output); - - const d01 = out.rows.find(x => x.item === 'D-01'); - assert.ok(d01, 'D-01 row must appear even when not covered'); - assert.strictEqual(d01.status, 'Not covered', - 'D-01 must be Not covered (not silently absent) when plan omits it'); - }); - - // ── Test 3 (counter-test): bare CONTEXT.md still works — no regression ─── - - test('bare CONTEXT.md still works (regression guard)', () => { - writeContextAs('CONTEXT.md', [ - { id: 'D-05', text: 'Bare form decision' }, - ]); - writePlan('01', '# Plan\n\nImplements D-05.\n'); - - const r = runGsdTools(['gap-analysis', '--phase-dir', phaseDir], tmpDir); - assert.ok(r.success, `gap-analysis failed: ${r.error}`); - const out = JSON.parse(r.output); - - const d05 = out.rows.find(x => x.item === 'D-05'); - assert.ok(d05, 'D-05 must appear when CONTEXT.md uses bare filename'); - assert.strictEqual(d05.status, 'Covered', 'D-05 must be Covered'); - }); - - // ── Test 4: deeper padded prefix (02.1-CONTEXT.md) ─────────────────────── - - test('multi-segment padded prefix (02.1-CONTEXT.md) decisions appear in gap report', () => { - writeContextAs('02.1-CONTEXT.md', [ - { id: 'D-03', text: 'Use postgres' }, - ]); - writePlan('01', '# Plan\n\nImplements D-03.\n'); - - const r = runGsdTools(['gap-analysis', '--phase-dir', phaseDir], tmpDir); - assert.ok(r.success, `gap-analysis failed: ${r.error}`); - const out = JSON.parse(r.output); - - const d03 = out.rows.find(x => x.item === 'D-03'); - assert.ok(d03, 'D-03 must appear from 02.1-CONTEXT.md'); - assert.strictEqual(d03.status, 'Covered'); - }); - - // ── Test 5: findContextMdIn helper unit test ───────────────────────────── - - test('findContextMdIn helper returns padded filename when present', () => { - const { findContextMdIn } = require('../gsd-core/bin/lib/planning-workspace.cjs'); - // Write 01-CONTEXT.md into the phase dir (already created in beforeEach) - fs.writeFileSync(path.join(phaseDir, '01-CONTEXT.md'), '# context\n'); - - const found = findContextMdIn(phaseDir); - assert.strictEqual(found, '01-CONTEXT.md', - 'findContextMdIn must return the padded-prefix filename'); - }); - - test('findContextMdIn helper returns bare filename when only bare form exists', () => { - const { findContextMdIn } = require('../gsd-core/bin/lib/planning-workspace.cjs'); - fs.writeFileSync(path.join(phaseDir, 'CONTEXT.md'), '# context\n'); - - const found = findContextMdIn(phaseDir); - assert.strictEqual(found, 'CONTEXT.md', - 'findContextMdIn must return CONTEXT.md for bare form'); - }); - - test('findContextMdIn helper returns null when no CONTEXT.md exists', () => { - const { findContextMdIn } = require('../gsd-core/bin/lib/planning-workspace.cjs'); - // phaseDir exists but is empty (no CONTEXT.md) - const found = findContextMdIn(phaseDir); - assert.strictEqual(found, null, - 'findContextMdIn must return null when no CONTEXT.md exists'); - }); - - // ── Test 5b: findContextMdIn accepts pre-read files array (avoids double readdirSync) ── - - test('findContextMdIn accepts an already-read files array (avoids double readdirSync)', () => { - const { findContextMdIn } = require('../gsd-core/bin/lib/planning-workspace.cjs'); - // Passing an array directly should behave identically to passing a directory path. - assert.strictEqual(findContextMdIn(['CONTEXT.md', 'other.md']), 'CONTEXT.md', - 'bare form found in array'); - assert.strictEqual(findContextMdIn(['01-CONTEXT.md', 'other.md']), '01-CONTEXT.md', - 'padded form found in array'); - assert.strictEqual(findContextMdIn(['unrelated.md']), null, - 'returns null when no CONTEXT.md in array'); - // Bare wins over padded when both are present - assert.strictEqual(findContextMdIn(['01-CONTEXT.md', 'CONTEXT.md']), 'CONTEXT.md', - 'bare form preferred over padded form when both in array'); - }); - - // ── Test 6: dual-file precedence — bare CONTEXT.md wins over padded form ── - - test('findContextMdIn prefers bare CONTEXT.md over padded form (helper level)', () => { - const { findContextMdIn } = require('../gsd-core/bin/lib/planning-workspace.cjs'); - // Write BOTH forms into the phase directory - fs.writeFileSync(path.join(phaseDir, 'CONTEXT.md'), '# bare context\n'); - fs.writeFileSync(path.join(phaseDir, '01-CONTEXT.md'), '# padded context\n'); - - const found = findContextMdIn(phaseDir); - assert.strictEqual(found, 'CONTEXT.md', - 'findContextMdIn must return bare CONTEXT.md when both forms exist — matches pre-refactor gap-checker behavior'); - }); - - test('gap-analysis uses bare CONTEXT.md decisions when both forms exist (integration level)', () => { - // Bare form has D-BARE; padded form has D-PADDED. - // If the integration path resolves bare correctly, only D-BARE appears in the report. - const bareContent = - '# Phase Context\n\n\n## Implementation Decisions\n\n- **D-BARE:** From bare form\n\n'; - const paddedContent = - '# Phase Context\n\n\n## Implementation Decisions\n\n- **D-PADDED:** From padded form\n\n'; - fs.writeFileSync(path.join(phaseDir, 'CONTEXT.md'), bareContent); - fs.writeFileSync(path.join(phaseDir, '01-CONTEXT.md'), paddedContent); - - writePlan('01', '# Plan\n\nImplements D-BARE.\n'); - - const r = runGsdTools(['gap-analysis', '--phase-dir', phaseDir], tmpDir); - assert.ok(r.success, `gap-analysis failed: ${r.error}`); - const out = JSON.parse(r.output); - - const dBare = out.rows.find(x => x.item === 'D-BARE'); - const dPadded = out.rows.find(x => x.item === 'D-PADDED'); - - assert.ok(dBare, 'D-BARE (from bare CONTEXT.md) must appear in gap report'); - assert.ok(!dPadded, 'D-PADDED (from 01-CONTEXT.md) must NOT appear — bare form takes precedence'); - assert.strictEqual(dBare.status, 'Covered', 'D-BARE must be Covered'); - }); -}); diff --git a/tests/bug-384-agents-runtime-aware.test.cjs b/tests/bug-384-agents-runtime-aware.test.cjs deleted file mode 100644 index b4e429b6e..000000000 --- a/tests/bug-384-agents-runtime-aware.test.cjs +++ /dev/null @@ -1,151 +0,0 @@ -/** - * Regression test for bug #384 — getAgentsDir() is runtime-blind. - * - * Before the fix, getAgentsDir() always resolved to the Claude path - * (~/.claude/agents) regardless of the active runtime, so on an OpenCode - * install checkAgentsInstalled() always returned agents_installed=false and - * agent_runtime was not surfaced at all. - * - * After the fix: - * - GSD_RUNTIME=opencode + OPENCODE_CONFIG_DIR pointing at a temp dir → - * agents_installed=true, agent_runtime='opencode', agents_dir under the - * opencode config dir - * - No GSD_RUNTIME + GSD_AGENTS_DIR pointing at a temp dir → - * agents_installed=true, agent_runtime='claude' - * - GSD_RUNTIME=opencode but agents dir empty → - * agents_installed=false, agent_runtime='opencode' - */ - -const { test, describe, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); -const os = require('os'); -const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); - -const MODEL_PROFILES = require('../gsd-core/bin/lib/model-profiles.cjs').MODEL_PROFILES; -const EXPECTED_AGENTS = Object.keys(MODEL_PROFILES); - -/** - * Create an agents directory under configDir/agents and populate it with - * the expected agent .md files. - */ -function createAgentsInConfigDir(configDir) { - const agentsDir = path.join(configDir, 'agents'); - fs.mkdirSync(agentsDir, { recursive: true }); - for (const name of EXPECTED_AGENTS) { - fs.writeFileSync( - path.join(agentsDir, `${name}.md`), - `---\nname: ${name}\ndescription: Test agent\ntools: Read, Bash\ncolor: cyan\n---\nAgent content.\n` - ); - } - return agentsDir; -} - -describe('bug #384 — getAgentsDir() is runtime-aware', () => { - let tmpDir; - let opencodeConfigDir; - - beforeEach(() => { - tmpDir = createTempProject(); - // Separate temp dir to act as the opencode global config dir - opencodeConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-opencode-')); - }); - - afterEach(() => { - cleanup(tmpDir); - cleanup(opencodeConfigDir); - }); - - // ── Test 1: opencode runtime resolves the opencode agents path ────────────── - - test('GSD_RUNTIME=opencode finds agents under OPENCODE_CONFIG_DIR/agents', () => { - // Place agents under the opencode config dir that getGlobalConfigDir('opencode') - // will return when OPENCODE_CONFIG_DIR is set. - const agentsDir = createAgentsInConfigDir(opencodeConfigDir); - - const result = runGsdTools( - ['init', 'quick', 'test description', '--raw'], - tmpDir, - { - GSD_RUNTIME: 'opencode', - OPENCODE_CONFIG_DIR: opencodeConfigDir, - // Ensure the process HOME does NOT have a conflicting ~/.claude/agents - // that might accidentally produce a false positive via GSD_AGENTS_DIR - // (we must NOT set GSD_AGENTS_DIR here — the whole point is that the fix - // uses the runtime-aware path without needing GSD_AGENTS_DIR). - } - ); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - - assert.strictEqual(output.agents_installed, true, - `agents_installed must be true when agents exist under OPENCODE_CONFIG_DIR/agents. ` + - `agents_dir=${output.agents_dir}, agent_runtime=${output.agent_runtime}`); - - assert.strictEqual(output.agent_runtime, 'opencode', - 'agent_runtime must be "opencode" when GSD_RUNTIME=opencode'); - - assert.strictEqual(output.agents_dir, agentsDir, - `agents_dir must point at the opencode agents dir (${agentsDir}), got: ${output.agents_dir}`); - }); - - // ── Test 2: claude fallback via GSD_AGENTS_DIR ────────────────────────────── - - test('default runtime (no GSD_RUNTIME) with GSD_AGENTS_DIR → agents_installed=true, agent_runtime=claude', () => { - // Classic GSD_AGENTS_DIR override: no runtime set, use the env shortcut - createAgentsInConfigDir(tmpDir); - // GSD_AGENTS_DIR points directly at the agents dir (not the config dir) - const directAgentsDir = path.join(tmpDir, 'agents'); - - const result = runGsdTools( - ['init', 'quick', 'test description', '--raw'], - tmpDir, - { - GSD_AGENTS_DIR: directAgentsDir, - // Explicitly unset GSD_RUNTIME so no runtime override applies - GSD_RUNTIME: '', - } - ); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - - assert.strictEqual(output.agents_installed, true, - `agents_installed must be true when GSD_AGENTS_DIR points at a populated agents dir. ` + - `agents_dir=${output.agents_dir}`); - - assert.strictEqual(output.agent_runtime, 'claude', - 'agent_runtime must be "claude" when no GSD_RUNTIME is set'); - - assert.strictEqual(output.agents_dir, directAgentsDir, - `agents_dir must match GSD_AGENTS_DIR override`); - }); - - // ── Test 3 (negative): opencode runtime, empty agents dir ─────────────────── - - test('GSD_RUNTIME=opencode with empty agents dir → agents_installed=false, agent_runtime still surfaced', () => { - // Create the opencode config dir but leave agents/ empty (no files) - const emptyAgentsDir = path.join(opencodeConfigDir, 'agents'); - fs.mkdirSync(emptyAgentsDir, { recursive: true }); - - const result = runGsdTools( - ['init', 'quick', 'test description', '--raw'], - tmpDir, - { - GSD_RUNTIME: 'opencode', - OPENCODE_CONFIG_DIR: opencodeConfigDir, - } - ); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - - assert.strictEqual(output.agents_installed, false, - 'agents_installed must be false when agents dir is empty'); - - assert.strictEqual(output.agent_runtime, 'opencode', - 'agent_runtime must still be surfaced even when agents are missing'); - }); -}); diff --git a/tests/bug-447-gap-analysis-phase-req-ids.test.cjs b/tests/bug-447-gap-analysis-phase-req-ids.test.cjs deleted file mode 100644 index 918bf9fc7..000000000 --- a/tests/bug-447-gap-analysis-phase-req-ids.test.cjs +++ /dev/null @@ -1,376 +0,0 @@ -'use strict'; - -/** - * Bug #447: plan-phase §13e gap-analysis ignores phase_req_ids → false-positive - * coverage gaps. - * - * Root cause: runGapAnalysis() diffs the ENTIRE REQUIREMENTS.md against the - * phase's plans, with no awareness of phase_req_ids. §13 (Requirements Coverage - * Gate) skips when phase_req_ids is null/TBD, but §13e never inherited that - * scoping contract — so a phase that maps no requirements reports every - * unrelated project REQ-ID as "Not covered". - * - * Fix: teach the gap-analysis CLI a --phase-req-ids option (the durable home for - * the scoping contract), mirroring §13: - * - null / TBD / empty → skip the REQUIREMENTS.md comparison entirely - * (CONTEXT.md decisions are still reported). - * - explicit ID list → restrict the comparison to those IDs. - * - flag absent → backward-compatible (compare the whole file). - */ - -const { describe, test, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); -const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); -const { normalizePhaseReqIds } = require('../gsd-core/bin/lib/gap-checker.cjs'); - -describe('gap-analysis --phase-req-ids scoping (#447)', () => { - let tmpDir; - let phaseDir; - - function writeRequirements(ids) { - const lines = ids.map((id, i) => `- [ ] **${id}** Requirement ${i + 1} description`); - fs.writeFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), - `# Requirements\n\n${lines.join('\n')}\n`); - } - - function writeContext(decisions) { - const dLines = decisions.map(d => `- **${d.id}:** ${d.text}`).join('\n'); - fs.writeFileSync(path.join(phaseDir, 'CONTEXT.md'), - `# Phase Context\n\n\n## Implementation Decisions\n\n${dLines}\n\n`); - } - - function writePlan(name, body) { - fs.writeFileSync(path.join(phaseDir, `${name}-PLAN.md`), body); - } - - function reqRows(out) { - return out.rows.filter(r => r.source === 'REQUIREMENTS.md').map(r => r.item); - } - - beforeEach(() => { - tmpDir = createTempProject(); - phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test'); - fs.mkdirSync(phaseDir, { recursive: true }); - const r = runGsdTools('config-ensure-section', tmpDir); - assert.ok(r.success, `config-ensure-section failed: ${r.error}`); - }); - - afterEach(() => cleanup(tmpDir)); - - // ── The core bug ───────────────────────────────────────────────────────────── - - test('phase mapping no REQs (--phase-req-ids TBD) reports zero REQUIREMENTS.md rows', () => { - // A REQUIREMENTS.md full of IDs that belong to OTHER phases/milestones. - writeRequirements(['BACK-01', 'WEB-03', 'API-07', 'DATA-02']); - writePlan('01', '# Plan 1\n\nStandalone phase, maps no project requirements.\n'); - - const r = runGsdTools( - ['gap-analysis', '--phase-dir', phaseDir, '--phase-req-ids', 'TBD'], tmpDir); - assert.ok(r.success, `gap-analysis failed: ${r.error}`); - const out = JSON.parse(r.output); - - assert.deepStrictEqual(reqRows(out), [], - 'a phase that maps no REQ-IDs must not report unrelated project requirements as gaps'); - assert.strictEqual(out.counts.uncovered, 0, - 'no false-positive "not covered" rows for an unmapped phase'); - }); - - test('--phase-req-ids null behaves the same as TBD (skip requirements)', () => { - writeRequirements(['BACK-01', 'WEB-03']); - writePlan('01', '# Plan\n\nNo mapped reqs.\n'); - - const r = runGsdTools( - ['gap-analysis', '--phase-dir', phaseDir, '--phase-req-ids', 'null'], tmpDir); - assert.ok(r.success, r.error); - const out = JSON.parse(r.output); - assert.deepStrictEqual(reqRows(out), []); - }); - - // ── Scoped to a mapped subset ──────────────────────────────────────────────── - - test('explicit ID list restricts the comparison to those REQ-IDs', () => { - writeRequirements(['REQ-01', 'REQ-02', 'REQ-03']); - // Plan covers REQ-01 only; REQ-02 is mapped to the phase but not yet addressed. - writePlan('01', '# Plan\n\nImplements REQ-01 only.\n'); - - const r = runGsdTools( - ['gap-analysis', '--phase-dir', phaseDir, '--phase-req-ids', 'REQ-01,REQ-02'], tmpDir); - assert.ok(r.success, r.error); - const out = JSON.parse(r.output); - - assert.deepStrictEqual(reqRows(out).sort(), ['REQ-01', 'REQ-02'], - 'only the phase-mapped REQ-IDs are considered; REQ-03 (another phase) is excluded'); - const req01 = out.rows.find(x => x.item === 'REQ-01'); - const req02 = out.rows.find(x => x.item === 'REQ-02'); - assert.strictEqual(req01.status, 'Covered'); - assert.strictEqual(req02.status, 'Not covered'); - }); - - test('JSON-array-ish value (["REQ-01"]) is tolerated and scoped', () => { - writeRequirements(['REQ-01', 'REQ-02']); - writePlan('01', '# Plan\n\nImplements REQ-01.\n'); - - const r = runGsdTools( - ['gap-analysis', '--phase-dir', phaseDir, '--phase-req-ids', '["REQ-01"]'], tmpDir); - assert.ok(r.success, r.error); - const out = JSON.parse(r.output); - assert.deepStrictEqual(reqRows(out), ['REQ-01']); - }); - - // ── CONTEXT.md decisions are unaffected by req scoping ─────────────────────── - - test('CONTEXT.md decisions are still reported when requirements are skipped', () => { - writeRequirements(['BACK-01', 'WEB-03']); - writeContext([{ id: 'D-01', text: 'Use a local notification daemon' }]); - writePlan('01', '# Plan\n\nUnrelated work, no decisions addressed.\n'); - - const r = runGsdTools( - ['gap-analysis', '--phase-dir', phaseDir, '--phase-req-ids', 'TBD'], tmpDir); - assert.ok(r.success, r.error); - const out = JSON.parse(r.output); - - assert.deepStrictEqual(reqRows(out), [], 'requirements skipped'); - const d01 = out.rows.find(x => x.item === 'D-01'); - assert.ok(d01, 'CONTEXT.md decision D-01 must still be reported'); - assert.strictEqual(d01.source, 'CONTEXT.md'); - assert.strictEqual(d01.status, 'Not covered'); - }); - - // ── Parser robustness (workflow passes the roadmap value verbatim) ─────────── - - test('whitespace/newline-separated IDs are tolerated and scoped', () => { - writeRequirements(['REQ-01', 'REQ-02', 'REQ-03']); - writePlan('01', '# Plan\n\nImplements the first one.\n'); - const r = runGsdTools( - ['gap-analysis', '--phase-dir', phaseDir, '--phase-req-ids', 'REQ-01 REQ-02\nREQ-03'], tmpDir); - assert.ok(r.success, r.error); - const out = JSON.parse(r.output); - assert.deepStrictEqual(reqRows(out).sort(), ['REQ-01', 'REQ-02', 'REQ-03']); - }); - - // ── Backward compatibility ─────────────────────────────────────────────────── - - test('flag absent → whole REQUIREMENTS.md is compared (unchanged behavior)', () => { - writeRequirements(['REQ-01', 'REQ-02']); - writePlan('01', '# Plan\n\nImplements REQ-01 only.\n'); - - const r = runGsdTools(['gap-analysis', '--phase-dir', phaseDir], tmpDir); - assert.ok(r.success, r.error); - const out = JSON.parse(r.output); - assert.deepStrictEqual(reqRows(out).sort(), ['REQ-01', 'REQ-02'], - 'with no --phase-req-ids, all requirements are still reported (back-compat)'); - }); - - // ── §13e wiring: init.plan-phase --pick phase_req_ids → gap-analysis ───────── - // Guards the exact query the workflow uses. `roadmap.get-phase` returns raw - // phase TEXT (not JSON), so --pick yields nothing there; the scoping value - // must come from `init.plan-phase`. This test would have caught using the - // wrong query (which silently skips requirements for every phase). - - function writeRoadmap(reqLine) { - fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap\n\n## Phase 1: Test Phase\n**Goal:** Do the thing\n${reqLine}**Success Criteria:**\n- It works\n`); - } - - test('init.plan-phase --pick phase_req_ids exposes the mapped IDs, and gap-analysis scopes to them', () => { - writeRoadmap('**Requirements:** REQ-01, REQ-02\n'); - writeRequirements(['REQ-01', 'REQ-02', 'REQ-03']); - writePlan('01', '# Plan\n\nImplements the first requirement only.\n'); - - const q = runGsdTools(['query', 'init.plan-phase', '1', '--pick', 'phase_req_ids'], tmpDir); - assert.ok(q.success, `init.plan-phase query failed: ${q.error}`); - const ids = q.output.trim(); - assert.match(ids, /REQ-01/, 'init.plan-phase must expose phase_req_ids (roadmap.get-phase does NOT)'); - - const r = runGsdTools(['gap-analysis', '--phase-dir', phaseDir, '--phase-req-ids', ids], tmpDir); - assert.ok(r.success, r.error); - const out = JSON.parse(r.output); - assert.deepStrictEqual(reqRows(out).sort(), ['REQ-01', 'REQ-02'], - 'gap report is scoped to the phase-mapped IDs; REQ-03 (another phase) is excluded'); - }); - - test('mapped REQ-ID absent from REQUIREMENTS.md appears as "Missing" row, not silently dropped', () => { - writeRequirements(['REQ-01']); - writePlan('01', '# Plan\n\nImplements REQ-01.\n'); - - const r = runGsdTools( - ['gap-analysis', '--phase-dir', phaseDir, '--phase-req-ids', 'REQ-01,REQ-99'], tmpDir); - assert.ok(r.success, r.error); - const out = JSON.parse(r.output); - - assert.deepStrictEqual(reqRows(out).sort(), ['REQ-01', 'REQ-99'], - 'REQ-99 (absent from REQUIREMENTS.md) must be present in the report, not silently dropped'); - const req99 = out.rows.find(x => x.item === 'REQ-99'); - assert.ok(req99, 'missing mapped ID must have an output row'); - assert.strictEqual(req99.status, 'Missing from REQUIREMENTS.md'); - assert.ok(out.counts.uncovered > 0, 'uncovered count must reflect the missing mapped ID'); - }); - - test('phase with no Requirements line → init.plan-phase yields empty → gap-analysis skips requirements', () => { - writeRoadmap(''); // no **Requirements:** line - writeRequirements(['REQ-01', 'REQ-02']); - writePlan('01', '# Plan\n\nStandalone phase.\n'); - - const q = runGsdTools(['query', 'init.plan-phase', '1', '--pick', 'phase_req_ids'], tmpDir); - assert.ok(q.success, q.error); - const ids = q.output.trim(); // expected empty - - // The workflow passes the (possibly empty) value through verbatim. - const r = runGsdTools(['gap-analysis', '--phase-dir', phaseDir, '--phase-req-ids', ids], tmpDir); - assert.ok(r.success, r.error); - const out = JSON.parse(r.output); - assert.deepStrictEqual(reqRows(out), [], - 'an unmapped phase reports no requirement gaps (the original #447 bug)'); - }); -}); - -/** - * #1269: `--phase-req-ids` range syntax (`-NN..-MM`) was treated - * as a literal ID, so a mapped range was reported as a coverage gap even when the - * individual IDs existed. normalizePhaseReqIds now expands a valid ascending - * same-prefix numeric range in place (preserving zero-pad width), and leaves any - * ambiguous/invalid range literal (fail-closed). These unit fixtures are folded - * here (the owning home for --phase-req-ids behavior) rather than a new - * bug-NNNN-* file, per the regression-test-placement policy. - */ -describe('#1269 — normalizePhaseReqIds range expansion', () => { - // ── The core bug: a range token must expand, not stay literal ──────────────── - - test('AC1: range + single ID expands in input order (was the literal-token bug)', () => { - // Pre-fix this returned ['SEL-01..SEL-03','TEST-01'] — the unexpanded range. - assert.deepStrictEqual( - normalizePhaseReqIds('SEL-01..SEL-03,TEST-01'), - ['SEL-01', 'SEL-02', 'SEL-03', 'TEST-01'], - 'a same-prefix ascending range must expand in place, preserving list order'); - }); - - test('AC2: zero-pad width is preserved across the expansion', () => { - assert.deepStrictEqual( - normalizePhaseReqIds('PREFIX-001..PREFIX-003'), - ['PREFIX-001', 'PREFIX-002', 'PREFIX-003']); - }); - - // ── AC3: existing behavior is unchanged ────────────────────────────────────── - - test('AC3: single-ID, comma/space/newline, and JSON-array-ish inputs unchanged', () => { - assert.deepStrictEqual(normalizePhaseReqIds('REQ-01'), ['REQ-01']); - assert.deepStrictEqual(normalizePhaseReqIds('REQ-01,REQ-02'), ['REQ-01', 'REQ-02']); - assert.deepStrictEqual(normalizePhaseReqIds('REQ-01 REQ-02'), ['REQ-01', 'REQ-02']); - assert.deepStrictEqual(normalizePhaseReqIds('REQ-01\nREQ-02'), ['REQ-01', 'REQ-02']); - assert.deepStrictEqual(normalizePhaseReqIds(['REQ-01', 'REQ-02']), ['REQ-01', 'REQ-02']); - assert.strictEqual(normalizePhaseReqIds(undefined), undefined); - assert.strictEqual(normalizePhaseReqIds(null), null); - assert.strictEqual(normalizePhaseReqIds('TBD'), null); - assert.strictEqual(normalizePhaseReqIds(''), null); - }); - - // ── AC4: invalid/ambiguous ranges stay LITERAL (fail-closed) ───────────────── - - test('AC4: mismatched-prefix range stays literal (no partial expansion)', () => { - assert.deepStrictEqual(normalizePhaseReqIds('SEL-01..TEST-03'), ['SEL-01..TEST-03']); - }); - - test('AC4: descending range stays literal', () => { - assert.deepStrictEqual(normalizePhaseReqIds('SEL-03..SEL-01'), ['SEL-03..SEL-01']); - }); - - test('AC4: non-numeric bound stays literal', () => { - assert.deepStrictEqual(normalizePhaseReqIds('SEL-0A..SEL-0C'), ['SEL-0A..SEL-0C']); - }); - - test('AC4: missing bound stays literal', () => { - assert.deepStrictEqual(normalizePhaseReqIds('SEL-01..'), ['SEL-01..']); - assert.deepStrictEqual(normalizePhaseReqIds('..SEL-03'), ['..SEL-03']); - }); - - test('AC4: an invalid range inside a mixed list stays literal while valid ones expand', () => { - assert.deepStrictEqual( - normalizePhaseReqIds('SEL-01..SEL-03,BAD-3..BAD-1'), - ['SEL-01', 'SEL-02', 'SEL-03', 'BAD-3..BAD-1']); - }); - - // ── Boundary fixtures ──────────────────────────────────────────────────────── - - test('boundary: single-element range (NN == MM)', () => { - assert.deepStrictEqual(normalizePhaseReqIds('SEL-02..SEL-02'), ['SEL-02']); - }); - - test('boundary: two-element range (NN == MM-1)', () => { - assert.deepStrictEqual(normalizePhaseReqIds('SEL-01..SEL-02'), ['SEL-01', 'SEL-02']); - }); - - test('boundary: differing zero-pad widths stay literal (fail-closed)', () => { - // Bounds of differing digit width are ambiguous: padding 'SEL-9' to width 2 - // would invent 'SEL-09', which may never appear unpadded in REQUIREMENTS. - // Fail closed — leave the whole token literal rather than guess. - assert.deepStrictEqual( - normalizePhaseReqIds('SEL-9..SEL-11'), - ['SEL-9..SEL-11']); - }); - - test('AC4: a range exceeding MAX_PHASE_REQ_RANGE stays literal (DoS guard)', () => { - // Same-width bounds (both 4 digits) so the differing-width guard does NOT fire - // first; span = 1001 - 1 + 1 = 1001 > MAX_PHASE_REQ_RANGE (1000) → the DoS cap - // is what keeps this literal. Isolates the cap branch from the width check. - const token = 'REQ-0001..REQ-1001'; - assert.deepStrictEqual(normalizePhaseReqIds(token), [token]); - }); - - test('multi-segment prefix with digits is handled (prefix compared verbatim)', () => { - assert.deepStrictEqual( - normalizePhaseReqIds('REQ2-01..REQ2-03'), - ['REQ2-01', 'REQ2-02', 'REQ2-03']); - }); -}); - -/** - * #1269 integration (AC5): the gap-analysis CLI must not flag a mapped range — - * or the IDs it expands to — as missing when those IDs exist in REQUIREMENTS.md. - */ -describe('#1269 — gap-analysis --phase-req-ids range (integration)', () => { - let tmpDir; - let phaseDir; - - function writeRequirements(ids) { - const lines = ids.map((id, i) => `- [ ] **${id}** Requirement ${i + 1} description`); - fs.writeFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), - `# Requirements\n\n${lines.join('\n')}\n`); - } - function writePlan(name, body) { - fs.writeFileSync(path.join(phaseDir, `${name}-PLAN.md`), body); - } - function reqRows(out) { - return out.rows.filter(r => r.source === 'REQUIREMENTS.md').map(r => r.item); - } - - beforeEach(() => { - tmpDir = createTempProject(); - phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test'); - fs.mkdirSync(phaseDir, { recursive: true }); - const r = runGsdTools('config-ensure-section', tmpDir); - assert.ok(r.success, `config-ensure-section failed: ${r.error}`); - }); - - afterEach(() => cleanup(tmpDir)); - - test('AC5: a mapped range is expanded and not flagged as missing when the IDs exist', () => { - writeRequirements(['SEL-01', 'SEL-02', 'SEL-03', 'TEST-01', 'OTHER-09']); - // The plan addresses each expanded SEL id and TEST-01. - writePlan('01', '# Plan\n\nImplements SEL-01, SEL-02, SEL-03, and TEST-01.\n'); - - const r = runGsdTools( - ['gap-analysis', '--phase-dir', phaseDir, '--phase-req-ids', 'SEL-01..SEL-03,TEST-01'], tmpDir); - assert.ok(r.success, r.error); - const out = JSON.parse(r.output); - - assert.deepStrictEqual(reqRows(out).sort(), ['SEL-01', 'SEL-02', 'SEL-03', 'TEST-01'], - 'the range expands to individual SEL IDs; the literal range token must NOT appear, and OTHER-09 (unmapped) is excluded'); - // The literal range token must never surface as a missing row. - assert.ok(!out.rows.some(x => x.item.includes('..')), - 'no range-literal row (e.g. "SEL-01..SEL-03") may be reported'); - assert.strictEqual(out.counts.uncovered, 0, - 'all expanded IDs exist in REQUIREMENTS.md and are covered — zero gaps'); - }); -}); diff --git a/tests/bug-474-clock-seam-date-determinism.test.cjs b/tests/bug-474-clock-seam-date-determinism.test.cjs deleted file mode 100644 index 9d3f0d328..000000000 --- a/tests/bug-474-clock-seam-date-determinism.test.cjs +++ /dev/null @@ -1,278 +0,0 @@ -// allow-test-rule: source-text-is-the-product -// STATE.md is the product surface; assertions on its text content test the -// deployed contract (date field written by the subprocess SUT). - -'use strict'; - -/** - * Bug #474 — clock seam: subprocess date-stamping must be deterministic. - * - * Tests in this file verify that: - * 1. state.cjs date-stamping is routed through realClock (not bare new Date()), - * so GSD_NOW_MS pins the written date deterministically in subprocess tests. - * 2. installer-migrations.cjs lock-loop timeout fires deterministically via - * the clock seam (in-process, using makeFakeClock — no subprocess needed). - */ - -const { describe, test, before, after } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); -const { runGsdTools, cleanup } = require('./helpers.cjs'); -const { createFixture } = require('./fixtures/index.cjs'); -const { makeFakeClock } = require('./helpers/clock.cjs'); - -// ───────────────────────────────────────────────────────────────────────────── -// §1 Subprocess date-pin: state advance-plan writes the pinned date -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Why advance-plan? - * cmdStateAdvancePlan captures `const today = new Date().toISOString().split('T')[0]` - * and writes it to "Last Activity" via stateReplaceFieldIfTemplate. - * The fixture below has Last Activity = 2024-01-10 (an ISO date, treated as a - * handler-generated template default by isStateTemplateDefault), so the field IS - * overwritten — making this the simplest single-subcommand probe of the bug. - */ - -// A fixed historical instant far in the past — will NEVER match today's real date. -const PINNED_MS = Date.parse('2020-06-15T12:00:00.000Z'); -const PINNED_DATE = '2020-06-15'; - -// A minimal STATE.md that satisfies advance-plan's parser: -// - Current Plan: 1 (not on last plan → normal-advance branch) -// - Total Plans in Phase: 3 -// - Last Activity: 2024-01-10 (ISO date → isStateTemplateDefault returns true → will be replaced) -const ADVANCE_FIXTURE = [ - '# Project State', - '', - '**Current Plan:** 1', - '**Total Plans in Phase:** 3', - '**Status:** Executing', - '**Last Activity:** 2024-01-10', -].join('\n') + '\n'; - -describe('bug-474: state date-stamping is pinned by GSD_NOW_MS', () => { - let tmpDir; - - before(() => { - // AAA — Arrange: create a temp project with the advance fixture - tmpDir = createFixture(); - fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), ADVANCE_FIXTURE); - }); - - after(() => { - cleanup(tmpDir); - }); - - test('state advance-plan writes pinned date (not real today) when GSD_NOW_MS is set', () => { - // AAA — Act: run advance-plan with a pinned historical timestamp - const result = runGsdTools('state advance-plan', tmpDir, { - GSD_TEST_MODE: '1', - GSD_NOW_MS: String(PINNED_MS), - }); - - assert.ok(result.success, `advance-plan failed unexpectedly: ${result.error}`); - - // AAA — Assert: the written STATE.md must contain the pinned date - const written = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); - - // Must contain the pinned historical date (2020-06-15) - assert.ok( - written.includes(PINNED_DATE), - `Expected STATE.md to contain pinned date ${PINNED_DATE}.\nActual STATE.md:\n${written}`, - ); - - // Must NOT contain today's real date — that would mean the seam is bypassed - const realToday = new Date().toISOString().split('T')[0]; - assert.ok( - !written.includes(realToday), - `Expected STATE.md NOT to contain real today (${realToday}) when time is pinned.\nActual STATE.md:\n${written}`, - ); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// §2 realClock GSD_NOW_MS invalid-input hardening -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Verify that malformed / out-of-range GSD_NOW_MS values fall back to the real - * clock instead of crashing with RangeError (issue #474 hardening). - * - * Each invalid input must: - * a) not throw from realClock.nowIso(), and - * b) produce a valid parseable ISO string (i.e. fell back to Date.now()). - * - * A valid pinned value must produce the expected date string. - */ - -describe('bug-474: realClock GSD_NOW_MS invalid-input hardening', () => { - const realClock = require('../gsd-core/bin/lib/clock.cjs').realClock; - - // Save and restore env so these tests cannot bleed into neighbouring tests. - let savedTestMode; - let savedNowMs; - - before(() => { - savedTestMode = process.env.GSD_TEST_MODE; - savedNowMs = process.env.GSD_NOW_MS; - process.env.GSD_TEST_MODE = '1'; - }); - - after(() => { - if (savedTestMode === undefined) { - delete process.env.GSD_TEST_MODE; - } else { - process.env.GSD_TEST_MODE = savedTestMode; - } - if (savedNowMs === undefined) { - delete process.env.GSD_NOW_MS; - } else { - process.env.GSD_NOW_MS = savedNowMs; - } - }); - - // AAA matrix: each invalid value must NOT crash and must fall back to the real clock. - const INVALID_INPUTS = [ - ['empty string', ''], - ['whitespace only', ' '], - ['alphabetic', 'abc'], - ['scientific notation', '1e30'], - ['decimal float', '12.5'], - ['integer > 8.64e15', '99999999999999999999'], - ]; - - for (const [label, value] of INVALID_INPUTS) { - test(`GSD_NOW_MS='${value}' (${label}) falls back to real clock — no crash, valid ISO`, () => { - // AAA — Arrange - process.env.GSD_NOW_MS = value; - - // AAA — Act + Assert: must not throw - assert.doesNotThrow( - () => realClock.nowIso(), - `realClock.nowIso() must not throw for GSD_NOW_MS='${value}'`, - ); - - // AAA — Assert: result is a valid ISO string (fell back to real clock) - const iso = realClock.nowIso(); - assert.ok( - !Number.isNaN(Date.parse(iso)), - `realClock.nowIso() must return a valid ISO date for GSD_NOW_MS='${value}', got: ${iso}`, - ); - }); - } - - test('GSD_NOW_MS valid decimal integer pins the clock', () => { - // AAA — Arrange: a known historical epoch - process.env.GSD_NOW_MS = String(PINNED_MS); - - // AAA — Act - const tod = realClock.today(); - - // AAA — Assert - assert.strictEqual(tod, PINNED_DATE, `realClock.today() must return pinned date ${PINNED_DATE}`); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// §3 In-process lock-loop: installer-migrations timeout fires deterministically -// ───────────────────────────────────────────────────────────────────────────── - -/** - * This test exercises acquireInstallMigrationLock's EEXIST retry loop via a - * makeFakeClock. The approach: - * - Write a lock file held by PID 1 (init/launchd — always alive on any OS) - * so neither isSameProcess nor isDeadProcess is true; stale-lock reclamation - * is NOT triggered, and the loop must reach the timeout check. - * - Use TIMEOUT_MS = 500 (non-zero) so the loop must retry at least once before - * the injected clock trips the deadline. A zero timeout would fire on the - * very first check without exercising clock.sleep() at all. - * - The fake clock's sleep(ms) ADVANCES its internal now by ms (confirmed from - * helpers/clock.cjs implementation), so the loop drives itself to termination - * purely through the injected clock without any wall-clock delay. - * - Post-throw assertions on clock.sleepCalls and clock.now() prove the seam: - * if the loop reverted to raw Date.now()/sleepSync, sleepCalls would be 0. - */ - -describe('bug-474: installer-migrations lock-loop timeout is deterministic via clock seam', () => { - test('makeFakeClock nowIso() and today() derive from pinned now()', () => { - // AAA — Arrange - const clock = makeFakeClock(PINNED_MS); - - // AAA — Act - const iso = clock.nowIso(); - const tod = clock.today(); - - // AAA — Assert - assert.strictEqual(iso, '2020-06-15T12:00:00.000Z', 'nowIso() must return ISO string of pinned epoch'); - assert.strictEqual(tod, PINNED_DATE, 'today() must return YYYY-MM-DD of pinned epoch'); - }); - - test('makeFakeClock advance() shifts nowIso() and today()', () => { - // AAA — Arrange: start at PINNED_MS, advance by 24 h - const clock = makeFakeClock(PINNED_MS); - const ONE_DAY_MS = 24 * 60 * 60 * 1000; - - // AAA — Act - clock.advance(ONE_DAY_MS); - - // AAA — Assert - assert.strictEqual(clock.today(), '2020-06-16', 'today() must reflect advanced time'); - }); - - test('acquireInstallMigrationLock timeout path fires deterministically via makeFakeClock', (t) => { - // AAA — Arrange - const os = require('os'); - const { acquireInstallMigrationLock } = require('../gsd-core/bin/lib/installer-migrations.cjs'); - const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-474-lock-')); - - t.after(() => { - cleanup(configDir); - }); - - const LOCK_NAME = 'gsd-install-migration.lock'; - const lockPath = path.join(configDir, LOCK_NAME); - - // Write a lock file held by process.ppid (the parent process, always alive - // and never equal to process.pid on every platform). pid 1 was used - // previously but isPidAlive(1) returns false on Windows (no init/launchd - // pid-1 concept), so the lock was treated as stale, reclaimed, and - // acquireInstallMigrationLock succeeded instead of throwing — failing the - // timeout assertion on windows-latest,22 (#474). process.ppid is a live, - // non-self process on all platforms, so the lock is correctly seen as held - // and the timeout path throws deterministically cross-platform. - fs.writeFileSync( - lockPath, - JSON.stringify({ pid: process.ppid, acquiredAt: new Date().toISOString() }) + '\n', - ); - - // TIMEOUT_MS is non-zero so the first EEXIST iteration does NOT immediately - // trip the deadline. The loop must call clock.sleep() at least once; that - // sleep() advances the fake clock past TIMEOUT_MS, causing the next check to - // throw. This proves the seam: if the loop used raw Date.now()/sleepSync, - // clock.sleepCalls would remain 0. - const TIMEOUT_MS = 500; - const clock = makeFakeClock(0); - - // AAA — Act + Assert: timeout error thrown with no real wall-clock delay - assert.throws( - () => acquireInstallMigrationLock(configDir, { timeoutMs: TIMEOUT_MS }, clock), - /lock|held/i, - 'Expected acquireInstallMigrationLock to throw with "lock"/"held" in message on timeout', - ); - - // AAA — Assert seam was actually exercised through the injected clock: - // If the loop used raw sleepSync instead of clock.sleep(), sleepCalls would be 0. - assert.ok( - clock.sleepCalls.length > 0, - 'loop must retry via injected clock.sleep() — proves seam wiring, not raw wall clock', - ); - - // The injected clock must have advanced past TIMEOUT_MS through its own sleep() calls. - assert.ok( - clock.now() >= TIMEOUT_MS, - `injected clock advanced past timeout deterministically: clock.now()=${clock.now()} must be >= TIMEOUT_MS=${TIMEOUT_MS}`, - ); - }); -}); diff --git a/tests/bug-492-effort-manifest-fallback.test.cjs b/tests/bug-492-effort-manifest-fallback.test.cjs deleted file mode 100644 index 8127b45c4..000000000 --- a/tests/bug-492-effort-manifest-fallback.test.cjs +++ /dev/null @@ -1,117 +0,0 @@ -'use strict'; -/** - * bug-492-effort-manifest-fallback.test.cjs - * - * Verifies resolveEffortInternal's fallback chain when no project config.json - * is present. - * - * Isolation strategy: every test that injects custom effort values writes - * them to a per-test ~/.gsd/defaults.json rooted under a tmpHome, pointed at - * via GSD_HOME. This avoids mutating the module-level CANONICAL_CONFIG_DEFAULTS - * singleton (which caused independence violations under parallel runs). - * - * Test 1 (pure manifest fallback): tmpDir WITH .planning/ but no config.json. - * GSD_HOME points to a bare tmpHome (no defaults.json). loadConfig sees - * .planning/ → returns effort:null → model-resolver reads CANONICAL_CONFIG_DEFAULTS - * directly for routing_tier_defaults. - * - * Tests 2-4 (global-defaults path): bare tmpDir (no .planning/) so loadConfig - * hits the ~/.gsd/defaults.json branch. A test-scoped defaults.json injects - * the desired effort sub-object; model-resolver then takes the effortCfg - * (non-null) branch — no singleton touched. - */ - -process.env.GSD_TEST_MODE = '1'; - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const os = require('node:os'); -const { cleanup } = require('./helpers.cjs'); -const { resolveEffortInternal } = require('../gsd-core/bin/lib/model-resolver.cjs'); - -/** Create a bare temp directory with no .planning/ structure */ -function createBareTmpDir(prefix = 'gsd-test-') { - return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); -} - -/** Create a temp home dir and write effort config into .gsd/defaults.json */ -function createTmpHomeWithEffort(effortConfig) { - const tmpHome = createBareTmpDir('gsd-home-'); - const gsdDir = path.join(tmpHome, '.gsd'); - fs.mkdirSync(gsdDir, { recursive: true }); - fs.writeFileSync( - path.join(gsdDir, 'defaults.json'), - JSON.stringify({ effort: effortConfig }) - ); - return tmpHome; -} - -describe('#492 manifest effort fallback', () => { - // These tests manage GSD_HOME per-test, so no shared beforeEach/afterEach. - - test('routing_tier_defaults manifest fallback still works when no config and no defaults.json', (t) => { - // .planning/ exists → loadConfig returns effort:null → model-resolver reads - // CANONICAL_CONFIG_DEFAULTS['effort']['routing_tier_defaults']['heavy'] = "xhigh". - const tmpDir = createBareTmpDir(); - fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true }); - const tmpHome = createBareTmpDir('gsd-home-'); - process.env.GSD_HOME = tmpHome; - t.after(() => { - delete process.env.GSD_HOME; - cleanup(tmpDir); - cleanup(tmpHome); - }); - - // gsd-planner's default tier is "heavy"; manifest routing_tier_defaults.heavy = "xhigh" - assert.strictEqual(resolveEffortInternal(tmpDir, 'gsd-planner'), 'xhigh'); - }); - - test('global-defaults effort.agent_overrides wins over routing_tier_defaults when no project config', (t) => { - // bare tmpDir (no .planning/) → loadConfig reads ~/.gsd/defaults.json - // which supplies effort.agent_overrides → resolveEffortInternal returns that value. - const tmpDir = createBareTmpDir(); - const tmpHome = createTmpHomeWithEffort({ agent_overrides: { 'gsd-planner': 'max' } }); - process.env.GSD_HOME = tmpHome; - t.after(() => { - delete process.env.GSD_HOME; - cleanup(tmpDir); - cleanup(tmpHome); - }); - - assert.strictEqual(resolveEffortInternal(tmpDir, 'gsd-planner'), 'max'); - }); - - test('global-defaults effort.default consulted for unknown agent with no project config', (t) => { - // effort.default in defaults.json wins for an agent with no tier mapping. - const tmpDir = createBareTmpDir(); - const tmpHome = createTmpHomeWithEffort({ default: 'max' }); - process.env.GSD_HOME = tmpHome; - t.after(() => { - delete process.env.GSD_HOME; - cleanup(tmpDir); - cleanup(tmpHome); - }); - - assert.strictEqual(resolveEffortInternal(tmpDir, 'fictional-agent-xyz-492'), 'max'); - }); - - test('global-defaults agent_overrides takes precedence over routing_tier_defaults', (t) => { - // agent_overrides is checked first (step 2), so "minimal" wins over - // routing_tier_defaults.heavy = "xhigh" (step 3). - const tmpDir = createBareTmpDir(); - const tmpHome = createTmpHomeWithEffort({ - agent_overrides: { 'gsd-planner': 'minimal' }, - routing_tier_defaults: { heavy: 'xhigh' }, - }); - process.env.GSD_HOME = tmpHome; - t.after(() => { - delete process.env.GSD_HOME; - cleanup(tmpDir); - cleanup(tmpHome); - }); - - assert.strictEqual(resolveEffortInternal(tmpDir, 'gsd-planner'), 'minimal'); - }); -}); diff --git a/tests/bug-500-planned-phase-progress-corruption.test.cjs b/tests/bug-500-planned-phase-progress-corruption.test.cjs deleted file mode 100644 index 2f330f312..000000000 --- a/tests/bug-500-planned-phase-progress-corruption.test.cjs +++ /dev/null @@ -1,145 +0,0 @@ -/** - * Bug #500: `state planned-phase` corrupts STATE.md milestone progress.* counters. - * - * Two independent defects: - * - * RC1 — plan-phase resyncs progress it should not touch. - * cmdStatePlannedPhase wrote via writeStateMd, which unconditionally runs - * syncStateFrontmatter and rebuilds progress.* from a half-planned disk - * snapshot, trampling curated counters. It must route through - * readModifyWriteStateMd(..., { resync: false }) like other body-only writes. - * - * RC2 — isRootPlanFile double-counts legacy `-PLAN--SUMMARY.md` as a plan. - * The final `/PLAN/i` fallback matches the substring "PLAN" inside a legacy - * summary name, so a 4-plan/4-summary phase scans as planCount:8 / completed:false - * instead of planCount:4 / completed:true. A summary is never a plan. - */ - -const { test, describe, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); -const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); -const planScan = require('../gsd-core/bin/lib/plan-scan.cjs'); -const { isRootPlanFile, scanPhasePlans } = planScan; - -describe('isRootPlanFile does not count legacy summaries as plans (#500 RC2)', () => { - test('legacy -PLAN--SUMMARY.md is not a root plan file', () => { - assert.equal(isRootPlanFile('14-PLAN-01-SUMMARY.md'), false); - }); - - test('legacy -PLAN-.md is still a root plan file', () => { - assert.equal(isRootPlanFile('14-PLAN-01.md'), true); - }); - - test('canonical -PLAN.md is still a root plan file', () => { - assert.equal(isRootPlanFile('01-PLAN.md'), true); - }); - - test('a 4-plan / 4-summary legacy phase scans as planCount:4 completed:true', () => { - const tmp = createTempProject(); - const phaseDir = path.join(tmp, '.planning', 'phases', '14-legacy'); - fs.mkdirSync(phaseDir, { recursive: true }); - for (let i = 1; i <= 4; i++) { - const nn = String(i).padStart(2, '0'); - fs.writeFileSync(path.join(phaseDir, `14-PLAN-${nn}.md`), '# Plan\n', 'utf-8'); - fs.writeFileSync(path.join(phaseDir, `14-PLAN-${nn}-SUMMARY.md`), '# Summary\n', 'utf-8'); - } - try { - const scan = scanPhasePlans(phaseDir); - assert.equal(scan.planCount, 4, `expected 4 plans, got ${scan.planCount}`); - assert.equal(scan.summaryCount, 4, `expected 4 summaries, got ${scan.summaryCount}`); - assert.equal(scan.completed, true, 'a fully-summarized phase must scan as completed'); - } finally { - cleanup(tmp); - } - }); -}); - -describe('state planned-phase preserves curated milestone progress.* (#500 RC1)', () => { - let tmpDir; - - // Curated progress counters that deliberately do NOT match what a disk scan - // would derive (disk has only one near-empty phase dir). The bug rebuilds - // progress.* from that disk snapshot, trampling these values. - const STATE = `--- -gsd_state_version: 1.0 -milestone: v3.0 -milestone_name: Active -status: in_progress -progress: - total_phases: 7 - completed_phases: 5 - total_plans: 99 - completed_plans: 88 - percent: 88 ---- - -# Project State - -## Configuration -Current Phase: 2 -Current Phase Name: builder -Total Plans in Phase: 0 -Status: Not started -Last Activity: TBD -Last Activity Description: pending - -## Current Position - -Phase: 2 (builder) -Status: Not started -Last activity: TBD -`; - - beforeEach(() => { - tmpDir = createTempProject(); - const planning = path.join(tmpDir, '.planning'); - fs.writeFileSync(path.join(planning, 'STATE.md'), STATE, 'utf-8'); - fs.writeFileSync( - path.join(planning, 'ROADMAP.md'), - '# Roadmap\n\n## 🚧 v3.0: Active\n\n### Phase 2: builder\n', - 'utf-8' - ); - fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8'); - // One sparse phase dir so a disk resync would derive small/zero counters. - const dir = path.join(planning, 'phases', '02-builder'); - fs.mkdirSync(dir, { recursive: true }); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - function readProgress() { - const md = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); - const block = md.split('---')[1] || ''; - const num = (key) => { - const m = block.match(new RegExp(`${key}:\\s*(\\d+)`)); - return m ? Number(m[1]) : null; - }; - return { - total_plans: num('total_plans'), - completed_plans: num('completed_plans'), - total_phases: num('total_phases'), - completed_phases: num('completed_phases'), - }; - } - - test('planned-phase updates per-phase body fields but leaves milestone progress.* untouched', () => { - const result = runGsdTools(['state', 'planned-phase', '--phase', '2', '--plans', '3'], tmpDir); - assert.equal(result.success, true, result.error || result.output); - - // The command did its real job: per-phase "Total Plans in Phase" was set. - const md = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); - assert.match(md, /Total Plans in Phase:\s*3/, 'per-phase Total Plans in Phase should be updated to 3'); - - // ...but the curated milestone-wide progress block is preserved verbatim. - assert.deepEqual(readProgress(), { - total_plans: 99, - completed_plans: 88, - total_phases: 7, - completed_phases: 5, - }, 'curated milestone progress.* must survive a planned-phase write'); - }); -}); diff --git a/tests/bug-503-update-agent-antigravity-detection.test.cjs b/tests/bug-503-update-agent-antigravity-detection.test.cjs deleted file mode 100644 index b5ce266fd..000000000 --- a/tests/bug-503-update-agent-antigravity-detection.test.cjs +++ /dev/null @@ -1,128 +0,0 @@ -'use strict'; -process.env.GSD_TEST_MODE = '1'; - -// allow-test-rule: source-text-is-the-product -// update.md's embedded classifier + cache-clear loop are workflow text the -// runtime loads and executes, so asserting on that text tests deployed -// behavior. The runtime/scope detection cascade itself moved out of inline -// bash into the update-context projection (issue #498), so the core guarantee -// is exercised behaviorally against resolveUpdateContext rather than by -// matching a `RUNTIME_DIRS=(...)` literal that no longer lives in update.md. -// Per CONTRIBUTING.md exception matrix. - -/** - * Bug #503: /gsd:update misclassifies local Antigravity (.agent) installs as claude - * - * The installer places a LOCAL Antigravity install in ./.agent/ - * (bin/install.js: getDirName('antigravity') === '.agent'). The /gsd:update - * detection cascade must map .agent -> antigravity across three surfaces: - * 1. the execution_context path classifier (update.md prose), - * 2. the RUNTIME_DIRS candidate table (now in the update-context projection), - * 3. the post-update cache-clear `for dir in` loop (update.md). - * - * Surface (2) is the original root cause and is now verified behaviorally: a - * LOCAL .agent install must resolve to the antigravity runtime. Before the fix - * (.agent absent from RUNTIME_DIRS) it fell through to UNKNOWN/claude. - */ - -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 UPDATE_MD = fs.readFileSync( - path.join(ROOT, 'gsd-core', 'workflows', 'update.md'), - 'utf-8', -); -const { resolveUpdateContext } = require( - path.join(ROOT, 'gsd-core', 'bin', 'lib', 'update-context.cjs'), -); - -function normKey(p) { return path.resolve(p).replace(/\\/g, '/').toLowerCase(); } -function fakeFs(files) { - const set = new Map(); - for (const [k, v] of Object.entries(files)) set.set(normKey(k), v); - return { - exists: (p) => set.has(normKey(p)), - readFile: (p) => { const k = normKey(p); return set.has(k) ? set.get(k) : null; }, - }; -} - -describe('/gsd:update detects local Antigravity (.agent / .agents) installs (#503 / #791)', () => { - test('projection resolves a LOCAL ./.agents install to the antigravity runtime (#791 canonical)', () => { - const HOME = '/home/u'; - const CWD = '/work/proj'; - const agentsDir = `${CWD}/.agents`; - const ffs = fakeFs({ - [`${agentsDir}/gsd-core/VERSION`]: '1.50.0\n', - [`${agentsDir}/gsd-core/workflows/update.md`]: 'x', - }); - const r = resolveUpdateContext({ home: HOME, cwd: CWD, env: {}, fs: ffs }); - assert.equal( - r.runtime, - 'antigravity', - `a local .agents install must map to the antigravity runtime, got "${r.runtime}"`, - ); - assert.equal(r.scope, 'LOCAL'); - assert.equal(r.installedVersion, '1.50.0'); - }); - - test('projection resolves a LOCAL ./.agent install to the antigravity runtime (#503 backward-compat)', () => { - const HOME = '/home/u'; - const CWD = '/work/proj'; - const agentDir = `${CWD}/.agent`; - const ffs = fakeFs({ - [`${agentDir}/gsd-core/VERSION`]: '1.40.0\n', - [`${agentDir}/gsd-core/workflows/update.md`]: 'x', - }); - const r = resolveUpdateContext({ home: HOME, cwd: CWD, env: {}, fs: ffs }); - assert.equal( - r.runtime, - 'antigravity', - `a legacy .agent install must still map to the antigravity runtime, got "${r.runtime}"`, - ); - assert.equal(r.scope, 'LOCAL'); - assert.equal(r.installedVersion, '1.40.0'); - }); - - test('execution_context classifier maps /.agents/ and /.agent/ paths to antigravity (update.md)', () => { - const hasAgentsClassifierRule = - /\/\.agents\/[^\r\n]*->[^\r\n]*antigravity/.test(UPDATE_MD); - assert.ok( - hasAgentsClassifierRule, - 'update.md classifier must map a `/.agents/` path to the `antigravity` runtime', - ); - const hasAgentClassifierRule = - /\/\.agent\/[^\r\n]*->[^\r\n]*antigravity/.test(UPDATE_MD); - assert.ok( - hasAgentClassifierRule, - 'update.md classifier must still map a `/.agent/` path to the `antigravity` runtime (backward-compat)', - ); - }); - - test('every runtime-dir `for dir in` loop in update.md includes .agents and .agent', () => { - // The LOCAL-scope discovery loop moved into the projection (#498); the - // post-update cache-clear loop remains inline and still enumerates the - // runtime config dirs as a literal `.claude ... .codex` list, so it must - // include both .agents (canonical, #791) and .agent (legacy, #503) or - // stale indicators could linger. - const runtimeDirLoops = UPDATE_MD - .split(/\r?\n/) - .filter((l) => /for dir in .*\.claude.*\.codex/.test(l)); - assert.ok( - runtimeDirLoops.length >= 1, - `expected at least 1 runtime-dir loop in update.md, found ${runtimeDirLoops.length}`, - ); - for (const loop of runtimeDirLoops) { - assert.ok( - /(^|\s)\.agents(\s|$)/.test(loop), - `every runtime-dir loop must include .agents (canonical), got: ${loop.trim()}`, - ); - assert.ok( - /(^|\s)\.agent(\s|$)/.test(loop), - `every runtime-dir loop must include .agent (legacy backward-compat), got: ${loop.trim()}`, - ); - } - }); -}); diff --git a/tests/bug-580-local-sh-hook-bash-wrapper.test.cjs b/tests/bug-580-local-sh-hook-bash-wrapper.test.cjs deleted file mode 100644 index d7bc2338d..000000000 --- a/tests/bug-580-local-sh-hook-bash-wrapper.test.cjs +++ /dev/null @@ -1,116 +0,0 @@ -'use strict'; - -/** - * Regression test for bug #580. - * - * LOCAL install under Claude Code on Windows: managed `.sh` hooks were emitted - * wrapped with an absolute `bash.exe` path via the `localShellCmd` arrow. - * Since Claude Code runs hook command strings INSIDE Git Bash, bash tries to - * exec bash → "cannot execute binary file". - * - * The GLOBAL path (`buildHookCommand`) already guarded win32+claude+.sh (#166). - * The LOCAL path (`localShellCmd`) did not. Fix: add `buildLocalShellHookCommand` - * and `shellHookOmitsBashRunner` to shell-command-projection.cjs, and use - * `buildLocalShellHookCommand` from install.js local-install path. - */ - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const path = require('node:path'); - -const projection = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'shell-command-projection.cjs')); -const { buildLocalShellHookCommand, shellHookOmitsBashRunner, projectLocalHookPrefix } = projection; - -describe('bug #580: local .sh hooks on Claude/Windows must NOT wrap with bash.exe', () => { - test('local .sh hook on Claude/Windows omits the bash.exe wrapper (#580)', () => { - const localPrefix = projectLocalHookPrefix({ runtime: 'claude', dirName: '.claude' }); - const result = buildLocalShellHookCommand({ - localPrefix, - hookFile: 'gsd-session-state.sh', - bashRunner: '"C:/Program Files/Git/bin/bash.exe"', - runtime: 'claude', - platform: 'win32', - }); - assert.equal(result, '"$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-session-state.sh'); - assert.ok(!result.includes('bash.exe'), `result must not contain bash.exe, got: ${result}`); - }); - - test('local .sh hook on Claude/Windows still emits script path when bash.exe is unresolved', () => { - const localPrefix = projectLocalHookPrefix({ runtime: 'claude', dirName: '.claude' }); - const result = buildLocalShellHookCommand({ - localPrefix, - hookFile: 'gsd-session-state.sh', - bashRunner: null, - runtime: 'claude', - platform: 'win32', - }); - assert.equal(result, '"$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-session-state.sh'); - }); - - test('local .sh hook on POSIX keeps the bash runner', () => { - const localPrefix = projectLocalHookPrefix({ runtime: 'claude', dirName: '.claude' }); - const result = buildLocalShellHookCommand({ - localPrefix, - hookFile: 'gsd-session-state.sh', - bashRunner: 'bash', - runtime: 'claude', - platform: 'linux', - }); - assert.equal(result, 'bash "$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-session-state.sh'); - }); - - test('local .sh hook on Windows non-Claude runtime keeps the bash runner', () => { - const localPrefix = projectLocalHookPrefix({ runtime: 'codex', dirName: '.claude' }); - const result = buildLocalShellHookCommand({ - localPrefix, - hookFile: 'gsd-session-state.sh', - bashRunner: '"C:/Program Files/Git/bin/bash.exe"', - runtime: 'codex', - platform: 'win32', - }); - assert.ok(result.includes('bash.exe'), `result must contain bash.exe, got: ${result}`); - assert.ok(result.startsWith('"C:/Program Files/Git/bin/bash.exe"'), `result must start with bash.exe token, got: ${result}`); - }); - - test('all four managed local .sh hooks drop the wrapper on Claude/Windows', () => { - const localPrefix = projectLocalHookPrefix({ runtime: 'claude', dirName: '.claude' }); - const hooks = [ - 'gsd-session-state.sh', - 'gsd-validate-commit.sh', - 'gsd-graphify-update.sh', - 'gsd-phase-boundary.sh', - ]; - for (const f of hooks) { - const result = buildLocalShellHookCommand({ - localPrefix, - hookFile: f, - bashRunner: '"C:/Program Files/Git/bin/bash.exe"', - runtime: 'claude', - platform: 'win32', - }); - assert.equal( - result, - `"$CLAUDE_PROJECT_DIR"/.claude/hooks/${f}`, - `expected script-only path for ${f}, got: ${result}`, - ); - assert.ok(!result.includes('bash.exe'), `result for ${f} must not contain bash.exe, got: ${result}`); - } - }); - - test('shellHookOmitsBashRunner truth table', () => { - // true only for win32 + claude + isShellHook:true - assert.equal(shellHookOmitsBashRunner({ platform: 'win32', runtime: 'claude', isShellHook: true }), true); - - // false for win32 + claude + isShellHook:false - assert.equal(shellHookOmitsBashRunner({ platform: 'win32', runtime: 'claude', isShellHook: false }), false); - - // false for win32 + codex + isShellHook:true - assert.equal(shellHookOmitsBashRunner({ platform: 'win32', runtime: 'codex', isShellHook: true }), false); - - // false for linux + claude + isShellHook:true - assert.equal(shellHookOmitsBashRunner({ platform: 'linux', runtime: 'claude', isShellHook: true }), false); - - // false for default args (no win32, no claude) - assert.equal(shellHookOmitsBashRunner(), false); - }); -}); diff --git a/tests/bug-663-redos-roadmap-phase-parsing.test.cjs b/tests/bug-663-redos-roadmap-phase-parsing.test.cjs deleted file mode 100644 index f4048c468..000000000 --- a/tests/bug-663-redos-roadmap-phase-parsing.test.cjs +++ /dev/null @@ -1,155 +0,0 @@ -/** - * Regression test for the ReDoS fixes in buildRoadmapPhaseVariants() and - * buildNotStartedPhaseVariants() (src/validate.cts, fix #663). - * - * The old patterns used nested quantifiers that caused catastrophic - * backtracking on crafted input: - * old: [\w][\w.-]*(?:-[\w.-]+)* ← ambiguous alternation → exponential - * new: [\w][\w.-]* ← single quantifier → linear - * - * The same nested-quantifier shape was fixed identically in src/verify.cts, - * src/commands.cts, and src/phase.cts. - * - * Part A: behavior preservation — the collapsed regex still matches the same - * phase identifiers as before on normal roadmap content. - * Part B: ReDoS adversarial fixtures — calls buildRoadmapPhaseVariants / - * buildNotStartedPhaseVariants with pathological input (a malformed heading - * or checklist line that has NO terminating colon). The adversarial input - * would cause catastrophic backtracking under the OLD nested-quantifier - * pattern; the fix makes backtracking linear. We assert on the STRUCTURED - * RESULT (the returned Set is empty — no match — because the colon is - * absent) rather than on elapsed time, in compliance with the - * local/no-elapsed-assertion ESLint rule. A { timeout: 5000 } backstop is - * retained so the test fails fast if a future regression reintroduces a - * slow pattern. - * - * Requirements: TEST-663-B - */ - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const { - buildRoadmapPhaseVariants, - buildNotStartedPhaseVariants, -} = require('../gsd-core/bin/lib/validate.cjs'); - -// ─── Part A: behavior preservation ─────────────────────────────────────────── - -describe('buildRoadmapPhaseVariants — behavior preservation (#663)', () => { - test('matches plain numeric heading (Phase 1:)', () => { - const content = [ - '# Roadmap', - '## Phase 1: Foo', - ].join('\n'); - - const { roadmapPhases } = buildRoadmapPhaseVariants(content); - assert.ok(roadmapPhases.has('1'), 'roadmapPhases should contain "1"'); - }); - - test('matches milestone-prefixed heading (Phase 2-01:)', () => { - const content = [ - '# Roadmap', - '### Phase 2-01: Bar', - ].join('\n'); - - const { roadmapPhases } = buildRoadmapPhaseVariants(content); - assert.ok(roadmapPhases.has('2-01'), 'roadmapPhases should contain "2-01"'); - }); - - test('matches bracket-prefixed heading ([GSD] Phase 3.2:)', () => { - const content = [ - '# Roadmap', - '### [GSD] Phase 3.2: Baz', - ].join('\n'); - - const { roadmapPhases } = buildRoadmapPhaseVariants(content); - assert.ok(roadmapPhases.has('3.2'), 'roadmapPhases should contain "3.2"'); - }); - - test('collects all phase identifiers from mixed-format roadmap', () => { - const content = [ - '# Roadmap', - '## Phase 1: Alpha', - '### Phase 2-01: Beta', - '### [GSD] Phase 3.2: Gamma', - ].join('\n'); - - const { roadmapPhases } = buildRoadmapPhaseVariants(content); - assert.ok(roadmapPhases.has('1'), 'should have phase 1'); - assert.ok(roadmapPhases.has('2-01'), 'should have phase 2-01'); - assert.ok(roadmapPhases.has('3.2'), 'should have phase 3.2'); - }); - - test('populates roadmapPhaseVariants with padding-normalized forms', () => { - const content = [ - '# Roadmap', - '### Phase 2-01: Beta', - ].join('\n'); - - const { roadmapPhaseVariants } = buildRoadmapPhaseVariants(content); - // phaseVariants() adds both padded and unpadded forms - assert.ok(roadmapPhaseVariants.has('2-01') || roadmapPhaseVariants.has('02-01'), - 'roadmapPhaseVariants should contain at least one padding form of 2-01'); - }); -}); - -describe('buildNotStartedPhaseVariants — behavior preservation (#663)', () => { - test('matches unchecked checklist item (- [ ] Phase 4-01:)', () => { - const content = [ - '# Roadmap', - '- [ ] Phase 4-01: Qux', - ].join('\n'); - - const notStarted = buildNotStartedPhaseVariants(content); - // phaseVariants() expands 4-01 into multiple forms; at minimum the raw form is present. - assert.ok(notStarted.has('4-01') || notStarted.has('04-01'), - 'notStarted should contain a variant of 4-01'); - }); - - test('does not pick up checked items', () => { - const content = [ - '# Roadmap', - '- [x] Phase 5: Done', - ].join('\n'); - - const notStarted = buildNotStartedPhaseVariants(content); - assert.strictEqual(notStarted.has('5'), false, 'completed phase should not be in notStarted'); - }); -}); - -// ─── Part B: ReDoS adversarial fixtures ────────────────────────────────────── - -describe('buildRoadmapPhaseVariants — ReDoS adversarial fixture (#663)', () => { - // Pathological input: a heading line where the phase-id segment consists of - // many consecutive "-a" chunks with NO terminating colon. Under the old - // nested-quantifier pattern ([\w.-]*(?:-[\w.-]+)*\s*:) the engine must - // explore exponentially many ways to partition the "-a" repetitions before - // concluding there is no match. The fixed single-quantifier pattern - // ([\w.-]*\s*:) backtracks linearly. We assert that the malformed heading - // yields NO match (empty roadmapPhases Set) — the correct behavior when the - // terminating colon is absent. The { timeout: 5000 } backstop catches any - // regression that re-introduces a slow pattern. - test('malformed heading without colon yields no phase match (adversarial input)', { timeout: 5000 }, () => { - const pathological = '## Phase a' + '-a'.repeat(32) + ' '; - const { roadmapPhases } = buildRoadmapPhaseVariants(pathological); - assert.strictEqual(roadmapPhases.size, 0, - 'a heading with no terminating colon should not match any phase'); - }); -}); - -describe('buildNotStartedPhaseVariants — ReDoS adversarial fixture (#663)', () => { - // Same analysis: the old uncheckedPattern used the same nested quantifier. - // A checklist-style line with many "-a" segments and no colon triggers the - // same catastrophic backtracking. Assert the correct structured result: - // the malformed line yields an empty notStarted Set. - test('malformed unchecked-item without terminator yields no phase match (adversarial input)', { timeout: 5000 }, () => { - // No trailing colon or whitespace: the regex terminator [:\s*] cannot match, - // so the engine must backtrack through all '-a' repetitions and conclude no - // match. Under the old nested-quantifier pattern this was exponential; - // under the fixed linear pattern it returns immediately with an empty Set. - const pathological = '- [ ] Phase a' + '-a'.repeat(32); - const notStarted = buildNotStartedPhaseVariants(pathological); - assert.strictEqual(notStarted.size, 0, - 'an unchecked-item line with no terminating colon or space should not match any phase'); - }); -}); diff --git a/tests/bug-730-milestone-phase-details-scope.test.cjs b/tests/bug-730-milestone-phase-details-scope.test.cjs deleted file mode 100644 index 2673af921..000000000 --- a/tests/bug-730-milestone-phase-details-scope.test.cjs +++ /dev/null @@ -1,275 +0,0 @@ -/** - * Regression test for bug #730: phase details defined under a milestone-scoped - * "## Milestone vX.Y — … (Phase Details)" section are invisible to phase - * resolution (getRoadmapPhaseInternal / init phase-op) when the flat shared - * "## Phase Details" section for an earlier milestone sits between the shared - * ## Phases checklist and the per-milestone Phase Details section. - * - * The bug manifests ONLY before any .planning/phases/ directory exists because - * findPhaseInternal masks it once the dir is created. RED step — tests 1 and 3 - * are expected to fail against current code. - */ - -const { test, describe, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); -const os = require('os'); -const { runGsdTools, cleanup } = require('./helpers.cjs'); - -// --------------------------------------------------------------------------- -// Shared fixture content -// --------------------------------------------------------------------------- - -const STATE_CONTENT = `--- -milestone: v1.1 ---- -`; - -const ROADMAP_CONTENT = `# Roadmap: Example - -## Phases - -- [x] **Phase 1: Setup** — initial scaffold - -### Milestone v1.1 — Second milestone (added 2026-01-01) - -- [ ] **Phase 2: Feature** — the new thing - -## Phase Details - -### Phase 1: Setup -**Goal:** scaffold the app. - -## Milestone v1.1 — Second milestone (Phase Details) - -### Phase 2: Feature -**Goal:** build the new thing. -`; - -// --------------------------------------------------------------------------- -// Helper: create a bare project with .planning/ but NO .planning/phases/ dir -// --------------------------------------------------------------------------- - -function createBareProject() { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-730-')); - fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true }); - return tmpDir; -} - -// --------------------------------------------------------------------------- -// Suite -// --------------------------------------------------------------------------- - -describe('bug #730 — milestone (Phase Details) section scope resolution', () => { - let dir; - - beforeEach(() => { - dir = createBareProject(); - fs.writeFileSync(path.join(dir, '.planning', 'STATE.md'), STATE_CONTENT, 'utf-8'); - fs.writeFileSync(path.join(dir, '.planning', 'ROADMAP.md'), ROADMAP_CONTENT, 'utf-8'); - }); - - afterEach(() => { - cleanup(dir); - }); - - // ------------------------------------------------------------------------- - // Test 1 (AC1): init phase-op resolves phase defined only under its - // per-milestone "(Phase Details)" section - // ------------------------------------------------------------------------- - test('init phase-op resolves a current-milestone phase defined only under its (Phase Details) section', () => { - const r = runGsdTools('init phase-op 2', dir); - assert.ok(r.success, `init phase-op 2 failed: ${r.error}`); - - const out = JSON.parse(r.output); - assert.strictEqual(out.phase_found, true, `phase_found should be true; got phase_found=${out.phase_found}, expected_phase_dir=${out.expected_phase_dir}`); - assert.strictEqual(out.phase_name, 'Feature', `phase_name should be 'Feature'; got '${out.phase_name}'`); - assert.strictEqual(out.padded_phase, '02', `padded_phase should be '02'; got '${out.padded_phase}'`); - assert.strictEqual(out.expected_phase_dir, '.planning/phases/02-feature', `expected_phase_dir should be '.planning/phases/02-feature'; got '${out.expected_phase_dir}'`); - }); - - // ------------------------------------------------------------------------- - // Test 2 (AC4): first-milestone phase still resolves via the flat - // "## Phase Details" section — no regression - // ------------------------------------------------------------------------- - test('init phase-op still resolves a first-milestone phase (no regression on flat Phase Details)', () => { - const r = runGsdTools('init phase-op 1', dir); - assert.ok(r.success, `init phase-op 1 failed: ${r.error}`); - - const out = JSON.parse(r.output); - assert.strictEqual(out.phase_found, true, `phase_found should be true for phase 1; got ${out.phase_found}`); - assert.strictEqual(out.phase_name, 'Setup', `phase_name should be 'Setup'; got '${out.phase_name}'`); - }); - - // ------------------------------------------------------------------------- - // Test 3 (AC5): getRoadmapPhaseInternal resolves the current-milestone phase - // directly before any phases/ dir exists - // ------------------------------------------------------------------------- - test('getRoadmapPhaseInternal resolves the current-milestone phase directly before any dir exists', () => { - const { getRoadmapPhaseInternal } = require('../gsd-core/bin/lib/roadmap-parser.cjs'); - - const res = getRoadmapPhaseInternal(dir, '2'); - assert.ok(res !== null && res !== undefined, `getRoadmapPhaseInternal returned null/undefined for phase 2`); - assert.strictEqual(res.found, true, `res.found should be true; got ${JSON.stringify(res)}`); - assert.strictEqual(res.phase_name, 'Feature', `res.phase_name should be 'Feature'; got '${res.phase_name}'`); - }); - - // ------------------------------------------------------------------------- - // Test 4 (AC3): validate health raises W006 for a current-milestone phase - // defined under (Phase Details) with no directory on disk. - // - // Before the fix, extractCurrentMilestone's slice stopped before the - // "## Milestone v1.1 — … (Phase Details)" section, so phase 2's - // "### Phase 2: Feature" header was invisible and W006 was never raised. - // After the fix the slice includes that section and W006 is emitted. - // - // This test uses its OWN local fixture (separate tmpdir) so it does not - // disturb the shared beforeEach/afterEach fixture used by tests 1–3. - // ------------------------------------------------------------------------- - test('validate health raises W006 for a started current-milestone phase defined under (Phase Details) with no directory', () => { - const localDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-730-t4-')); - try { - const planning = path.join(localDir, '.planning'); - fs.mkdirSync(planning, { recursive: true }); - - // STATE.md — milestone: v1.1 - fs.writeFileSync( - path.join(planning, 'STATE.md'), - `---\nmilestone: v1.1\n---\n`, - 'utf-8', - ); - - // ROADMAP.md — phase 2 is [x] (started/complete) so the not-started - // guard does NOT suppress W006. Phase 2's details live exclusively in - // the per-milestone "(Phase Details)" section (the blind-spot pre-fix). - fs.writeFileSync( - path.join(planning, 'ROADMAP.md'), - `# Roadmap: Example\n\n## Phases\n\n- [x] **Phase 1: Setup** — initial scaffold\n\n### Milestone v1.1 — Second milestone (added 2026-01-01)\n\n- [x] **Phase 2: Feature** — the new thing\n\n## Phase Details\n\n### Phase 1: Setup\n**Goal:** scaffold the app.\n\n## Milestone v1.1 — Second milestone (Phase Details)\n\n### Phase 2: Feature\n**Goal:** build the new thing.\n`, - 'utf-8', - ); - - // Create the phase 1 directory so phase 1 does NOT trigger W006. - // Phase 2 has NO directory — that's the missing-dir condition under test. - fs.mkdirSync(path.join(planning, 'phases', '01-setup'), { recursive: true }); - - const result = runGsdTools(['validate', 'health'], localDir); - const payload = JSON.parse(result.output); - const warnings = payload.warnings || []; - - // Find a W006 entry whose message references phase 2 (by number or name). - const w006ForPhase2 = warnings.find( - (w) => - w.code === 'W006' && - (/\b2\b/.test(w.message) || /\b02\b/.test(w.message) || /Feature/i.test(w.message)), - ); - - assert.ok( - w006ForPhase2 != null, - `Expected a W006 warning referencing phase 2 (Feature) — phase 2 is started ([x]) and has no directory on disk, ` + - `but its ### Phase 2: header lives in the Milestone v1.1 (Phase Details) section which was invisible before the fix. ` + - `Got warnings: ${JSON.stringify(warnings)}`, - ); - } finally { - cleanup(localDir); - } - }); - - // ------------------------------------------------------------------------- - // Test 5: three-milestone roadmap, current = latest (v1.2) - // ------------------------------------------------------------------------- - test('init phase-op resolves the latest milestone phase in a 3-milestone roadmap', () => { - const localDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-730-t5-')); - try { - const planning = path.join(localDir, '.planning'); - fs.mkdirSync(planning, { recursive: true }); - - fs.writeFileSync( - path.join(planning, 'STATE.md'), - `---\nmilestone: v1.2\n---\n`, - 'utf-8', - ); - - fs.writeFileSync( - path.join(planning, 'ROADMAP.md'), - `# Roadmap: Example\n\n## Phases\n\n- [x] **Phase 1: Setup** — done\n\n### Milestone v1.1 — Second (added 2026-01-01)\n\n- [x] **Phase 2: Feature** — done\n\n### Milestone v1.2 — Third (added 2026-02-01)\n\n- [ ] **Phase 3: Polish** — current\n\n## Phase Details\n\n### Phase 1: Setup\n**Goal:** scaffold.\n\n## Milestone v1.1 — Second (Phase Details)\n\n### Phase 2: Feature\n**Goal:** build.\n\n## Milestone v1.2 — Third (Phase Details)\n\n### Phase 3: Polish\n**Goal:** refine.\n`, - 'utf-8', - ); - - const r = runGsdTools('init phase-op 3', localDir); - assert.ok(r.success, `init phase-op 3 failed: ${r.error}`); - - const out = JSON.parse(r.output); - assert.strictEqual(out.phase_found, true, `phase_found should be true; got phase_found=${out.phase_found}`); - assert.strictEqual(out.phase_name, 'Polish', `phase_name should be 'Polish'; got '${out.phase_name}'`); - assert.strictEqual(out.padded_phase, '03', `padded_phase should be '03'; got '${out.padded_phase}'`); - assert.strictEqual(out.expected_phase_dir, '.planning/phases/03-polish', `expected_phase_dir should be '.planning/phases/03-polish'; got '${out.expected_phase_dir}'`); - } finally { - cleanup(localDir); - } - }); - - // ------------------------------------------------------------------------- - // Test 6: sub-milestone sharing a version prefix — closed sibling must NOT - // cross-pollinate into the active milestone's Phase Details lookup (#730) - // ------------------------------------------------------------------------- - test('init phase-op anchors Phase Details to the selected sub-milestone, not a closed same-prefix sibling', () => { - const localDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-730-t6-')); - try { - const planning = path.join(localDir, '.planning'); - fs.mkdirSync(planning, { recursive: true }); - - // STATE.md — milestone: v3.0 (matches v3.0-B active slice) - fs.writeFileSync( - path.join(planning, 'STATE.md'), - `---\nmilestone: v3.0\n---\n`, - 'utf-8', - ); - - // ROADMAP.md — v3.0-A is SHIPPED (closed), v3.0-B is active. - // The Phase Details for v3.0-A comes FIRST — without version-boundary - // anchoring the old code would grab it (first non-closed (Phase Details) - // heading outside the window), returning phase_name='Alpha' instead of 'Beta'. - fs.writeFileSync( - path.join(planning, 'ROADMAP.md'), - [ - '# Roadmap: Example', - '', - '## Phases', - '', - '### Milestone v3.0-A — First slice (added 2026-01-01) ✅ SHIPPED', - '', - '- [x] **Phase 1: Alpha** — done', - '', - '### Milestone v3.0-B — Second slice (added 2026-02-01)', - '', - '- [ ] **Phase 2: Beta** — current', - '', - '## Phase Details', - '', - '## Milestone v3.0-A — First slice (Phase Details)', - '', - '### Phase 1: Alpha', - '**Goal:** alpha goal.', - '', - '## Milestone v3.0-B — Second slice (Phase Details)', - '', - '### Phase 2: Beta', - '**Goal:** beta goal.', - '', - ].join('\n'), - 'utf-8', - ); - - const r = runGsdTools('init phase-op 2', localDir); - assert.ok(r.success, `init phase-op 2 failed: ${r.error}`); - - const out = JSON.parse(r.output); - assert.strictEqual(out.phase_found, true, `phase_found should be true; got phase_found=${out.phase_found}, output=${JSON.stringify(out)}`); - assert.strictEqual(out.phase_name, 'Beta', `phase_name should be 'Beta' (v3.0-B section), not '${out.phase_name}' (would indicate v3.0-A cross-pollination)`); - assert.strictEqual(out.expected_phase_dir, '.planning/phases/02-beta', `expected_phase_dir should be '.planning/phases/02-beta'; got '${out.expected_phase_dir}'`); - } finally { - cleanup(localDir); - } - }); -}); diff --git a/tests/bug-783-kilo-global-skills-base.test.cjs b/tests/bug-783-kilo-global-skills-base.test.cjs deleted file mode 100644 index 90a29c832..000000000 --- a/tests/bug-783-kilo-global-skills-base.test.cjs +++ /dev/null @@ -1,105 +0,0 @@ -'use strict'; -// Regression guard for bug #783. -// -// getGlobalSkillsBase('kilo') was returning ~/.config/kilo/skills (the XDG -// config dir) instead of ~/.kilo/skills — where Kilo Code actually discovers -// global skills per its docs: -// https://kilo.ai/docs/customize/skills -// "Global skills are located in the `.kilo` directory within your Home -// directory: ~/.kilo/skills/" -// -// The fix adds a special case in getGlobalSkillsBase() that resolves kilo's -// skills dir from HOME (not from the XDG config dir). The config dir at -// ~/.config/kilo is still CORRECT for commands (command/) and must stay -// unchanged — this test verifies both roles are separate. - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const path = require('node:path'); -const os = require('node:os'); - -const ROOT = path.join(__dirname, '..'); -const { - getGlobalConfigDir, - getGlobalSkillsBase, -} = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-homes.cjs')); - -// Helper: temporarily override env vars for a test, restoring them afterwards. -function withEnv(overrides, fn) { - const saved = {}; - for (const [key, value] of Object.entries(overrides)) { - saved[key] = process.env[key]; - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } - try { - return fn(); - } finally { - for (const [key] of Object.entries(overrides)) { - if (saved[key] === undefined) delete process.env[key]; - else process.env[key] = saved[key]; - } - } -} - -// Clear all kilo-relevant env vars so tests are hermetic. -const kiloEnvClears = { - KILO_CONFIG_DIR: undefined, - XDG_CONFIG_HOME: undefined, -}; - -describe('bug #783: kilo global skills dir is ~/.kilo/skills, not ~/.config/kilo/skills', () => { - test('getGlobalSkillsBase("kilo") resolves to ~/.kilo/skills', () => { - withEnv(kiloEnvClears, () => { - assert.strictEqual( - getGlobalSkillsBase('kilo'), - path.join(os.homedir(), '.kilo', 'skills'), - ); - }); - }); - - test('getGlobalConfigDir("kilo") still resolves to ~/.config/kilo (config dir unchanged)', () => { - withEnv(kiloEnvClears, () => { - assert.strictEqual( - getGlobalConfigDir('kilo'), - path.join(os.homedir(), '.config', 'kilo'), - ); - }); - }); - - test('kilo skills dir and config dir are decoupled (not equal, not nested)', () => { - withEnv(kiloEnvClears, () => { - const skillsBase = getGlobalSkillsBase('kilo'); - const configDir = getGlobalConfigDir('kilo'); - - assert.notStrictEqual(skillsBase, configDir, 'skills dir must differ from config dir'); - assert.ok( - !skillsBase.startsWith(configDir + path.sep), - `skills dir (${skillsBase}) must not be nested under config dir (${configDir})`, - ); - assert.ok( - !configDir.startsWith(skillsBase + path.sep), - `config dir (${configDir}) must not be nested under skills dir (${skillsBase})`, - ); - }); - }); - - test('getGlobalSkillsBase("kilo") is NOT affected by KILO_CONFIG_DIR override', () => { - // Skills always live in ~/.kilo/skills regardless of XDG/config-dir overrides. - withEnv({ KILO_CONFIG_DIR: '/tmp/custom-kilo-config', XDG_CONFIG_HOME: undefined }, () => { - assert.strictEqual( - getGlobalSkillsBase('kilo'), - path.join(os.homedir(), '.kilo', 'skills'), - ); - }); - }); - - test('getGlobalSkillsBase("kilo") is NOT affected by XDG_CONFIG_HOME override', () => { - withEnv({ KILO_CONFIG_DIR: undefined, XDG_CONFIG_HOME: '/tmp/custom-xdg' }, () => { - assert.strictEqual( - getGlobalSkillsBase('kilo'), - path.join(os.homedir(), '.kilo', 'skills'), - ); - }); - }); -}); diff --git a/tests/bug-892-validate-checklist-roadmap-phases.test.cjs b/tests/bug-892-validate-checklist-roadmap-phases.test.cjs deleted file mode 100644 index 4577d4419..000000000 --- a/tests/bug-892-validate-checklist-roadmap-phases.test.cjs +++ /dev/null @@ -1,290 +0,0 @@ -/** - * Regression test for #892: checklist-style roadmap phases (`- [x] **Phase NN: name**`) - * were silently skipped by `buildRoadmapPhaseVariants()`, causing W007 false positives - * on every on-disk phase dir when the project uses the checklist ROADMAP format. - * - * Covers: - * A. Unit-level: `buildRoadmapPhaseVariants()` in validate.cts recognises both - * checked (`- [x]`) and unchecked (`- [ ]`) checklist items. - * B. Integration: `validate health` emits NO W007 for a checklist-only roadmap - * whose phase dirs all appear in the checklist. - * C. Integration: `validate consistency` emits NO "exists on disk but not in - * ROADMAP.md" warning for the same checklist-only roadmap. - * - * Requirements: BUG-892 - */ -'use strict'; - -const { describe, test, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); -const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); -const { - buildRoadmapPhaseVariants, -} = require('../gsd-core/bin/lib/validate.cjs'); - -// ─── A: Unit-level ──────────────────────────────────────────────────────────── - -describe('buildRoadmapPhaseVariants — checklist format support (#892)', () => { - test('matches checked checklist item `- [x] **Phase 01: name**`', () => { - const content = [ - '# Roadmap', - '', - '- [x] **Phase 01: infrastructure-hardening**', - ].join('\n'); - - const { roadmapPhases } = buildRoadmapPhaseVariants(content); - assert.ok( - roadmapPhases.has('01') || roadmapPhases.has('1'), - `roadmapPhases should contain a variant of "01", got: ${JSON.stringify([...roadmapPhases])}` - ); - }); - - test('matches checked checklist item with uppercase X `- [X] **Phase 02: foo**`', () => { - const content = [ - '# Roadmap', - '', - '- [X] **Phase 02: feature-work**', - ].join('\n'); - - const { roadmapPhases } = buildRoadmapPhaseVariants(content); - assert.ok( - roadmapPhases.has('02') || roadmapPhases.has('2'), - `roadmapPhases should contain a variant of "02", got: ${JSON.stringify([...roadmapPhases])}` - ); - }); - - test('matches unchecked checklist item `- [ ] **Phase 03: name**`', () => { - // unchecked items are also phases — they just have not been started - const content = [ - '# Roadmap', - '', - '- [ ] **Phase 03: future-work**', - ].join('\n'); - - const { roadmapPhases } = buildRoadmapPhaseVariants(content); - assert.ok( - roadmapPhases.has('03') || roadmapPhases.has('3'), - `roadmapPhases should contain a variant of "03", got: ${JSON.stringify([...roadmapPhases])}` - ); - }); - - test('collects all phases from a pure checklist roadmap (no ## headings)', () => { - const content = [ - '# Roadmap', - '', - '- [x] **Phase 01: alpha**', - '- [x] **Phase 02: beta**', - '- [ ] **Phase 03: gamma**', - ].join('\n'); - - const { roadmapPhases } = buildRoadmapPhaseVariants(content); - const has = (id) => roadmapPhases.has(id) || roadmapPhases.has(id.replace(/^0+/, '')) || roadmapPhases.has(String(parseInt(id, 10)).padStart(2, '0')); - assert.ok(has('01'), 'should contain phase 01'); - assert.ok(has('02'), 'should contain phase 02'); - assert.ok(has('03'), 'should contain phase 03'); - }); - - test('populates roadmapPhaseVariants with padding-normalised forms for checklist phases', () => { - const content = [ - '# Roadmap', - '', - '- [x] **Phase 01: something**', - ].join('\n'); - - const { roadmapPhaseVariants } = buildRoadmapPhaseVariants(content); - // phaseVariants() adds both '1' and '01' forms - assert.ok( - roadmapPhaseVariants.has('1') || roadmapPhaseVariants.has('01'), - `roadmapPhaseVariants should contain at least one padding form, got: ${JSON.stringify([...roadmapPhaseVariants])}` - ); - }); - - test('mixed roadmap (headings + checklist) collects phases from both styles', () => { - const content = [ - '# Roadmap', - '', - '## Phase 1: heading-style', - '', - '- [x] **Phase 02: checklist-style**', - ].join('\n'); - - const { roadmapPhases } = buildRoadmapPhaseVariants(content); - assert.ok( - roadmapPhases.has('1') || roadmapPhases.has('01'), - 'should contain heading-style phase 1' - ); - assert.ok( - roadmapPhases.has('02') || roadmapPhases.has('2'), - 'should contain checklist-style phase 02' - ); - }); -}); - -// ─── B: validate health — no W007 for checklist-only roadmaps ──────────────── - -describe('validate health — checklist-style roadmap phases must not emit W007 (#892)', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempProject(); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('no W007 when phase dirs match checked checklist entries in ROADMAP.md', () => { - // Write PROJECT.md, STATE.md, config.json, and a checklist-only ROADMAP.md - fs.writeFileSync( - path.join(tmpDir, '.planning', 'PROJECT.md'), - '# Project\n\n## What This Is\n\nTest.\n\n## Core Value\n\nValue.\n\n## Requirements\n\nRequirements.\n' - ); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'STATE.md'), - '# Session State\n\n## Current Position\n\nPhase 1 in progress.\n' - ); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'config.json'), - JSON.stringify({ model_profile: 'balanced', commit_docs: true }, null, 2) - ); - - // Checklist-only ROADMAP: no ## Phase headings, only checklist items - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - [ - '# Roadmap', - '', - '- [x] **Phase 01: infrastructure-hardening**', - '- [x] **Phase 02: feature-work**', - '', - ].join('\n') - ); - - // Create matching phase directories on disk - fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-infrastructure-hardening'), { recursive: true }); - fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-feature-work'), { recursive: true }); - - const result = runGsdTools('validate health', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - const w007s = output.warnings.filter((w) => w.code === 'W007'); - assert.strictEqual( - w007s.length, - 0, - `W007 must not fire for phases whose dirs are listed in a checklist-style ROADMAP.md, got: ${JSON.stringify(w007s)}` - ); - }); - - test('W007 still fires when a phase dir is genuinely absent from a checklist roadmap', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'PROJECT.md'), - '# Project\n\n## What This Is\n\nTest.\n\n## Core Value\n\nValue.\n\n## Requirements\n\nRequirements.\n' - ); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'STATE.md'), - '# Session State\n\n## Current Position\n\nPhase 1 in progress.\n' - ); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'config.json'), - JSON.stringify({ model_profile: 'balanced', commit_docs: true }, null, 2) - ); - - // ROADMAP only lists phase 01, not phase 99 - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - [ - '# Roadmap', - '', - '- [x] **Phase 01: known-phase**', - '', - ].join('\n') - ); - - // Phase 01 dir is present but also an orphan phase 99 that is NOT in ROADMAP - fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-known-phase'), { recursive: true }); - fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '99-orphan'), { recursive: true }); - - const result = runGsdTools('validate health', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - const w007s = output.warnings.filter((w) => w.code === 'W007'); - assert.ok( - w007s.length > 0, - `W007 must still fire for a phase dir genuinely not listed in ROADMAP.md, got warnings: ${JSON.stringify(output.warnings)}` - ); - // Should only flag phase 99, not phase 01 - assert.ok( - w007s.some((w) => w.message.includes('99')), - `W007 should reference orphan phase 99, got: ${JSON.stringify(w007s)}` - ); - assert.ok( - !w007s.some((w) => w.message.includes('01') || w.message.includes('1')), - `W007 must NOT flag phase 01 which is in the checklist, got: ${JSON.stringify(w007s)}` - ); - }); -}); - -// ─── C: validate consistency — no false positive for checklist-only roadmaps ─ - -describe('validate consistency — checklist-style roadmap phases must not emit false warnings (#892)', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempProject(); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('no "exists on disk but not in ROADMAP.md" warning for checklist-matched phases', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'PROJECT.md'), - '# Project\n\n## What This Is\n\nTest.\n\n## Core Value\n\nValue.\n\n## Requirements\n\nRequirements.\n' - ); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'STATE.md'), - '# Session State\n\n## Current Position\n\nPhase 1 in progress.\n' - ); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'config.json'), - JSON.stringify({ model_profile: 'balanced', commit_docs: true }, null, 2) - ); - - // Checklist-only ROADMAP - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - [ - '# Roadmap', - '', - '- [x] **Phase 01: infrastructure-hardening**', - '- [x] **Phase 02: feature-work**', - '', - ].join('\n') - ); - - // Create matching phase directories - fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-infrastructure-hardening'), { recursive: true }); - fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-feature-work'), { recursive: true }); - - const result = runGsdTools('validate consistency', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - // No "exists on disk but not in ROADMAP" warnings for checklist-listed phases - const diskNotInRoadmapWarnings = (output.warnings || []).filter( - (w) => typeof w === 'string' - ? w.includes('exists on disk but not in ROADMAP') - : (w.message || '').includes('exists on disk but not in ROADMAP') - ); - assert.strictEqual( - diskNotInRoadmapWarnings.length, - 0, - `No "exists on disk but not in ROADMAP.md" warnings should fire for checklist-listed phases, got: ${JSON.stringify(diskNotInRoadmapWarnings)}` - ); - }); -}); diff --git a/tests/bug-924-claude-flat-skill-layout.test.cjs b/tests/bug-924-claude-flat-skill-layout.test.cjs deleted file mode 100644 index 2c4d43309..000000000 --- a/tests/bug-924-claude-flat-skill-layout.test.cjs +++ /dev/null @@ -1,189 +0,0 @@ -// allow-test-rule: source-text-is-the-product -// Reads installed SKILL.md files from a real install run — -// testing their on-disk layout tests the deployed contract. - -/** - * Regression test for bug #924. - * - * PR #883 accidentally nested concrete gsd-* skills 3 levels deep for the - * Claude global install: - * - * ~/.claude/skills/gsd-ns-/skills//SKILL.md - * - * Claude Code's skills discovery scans only ONE level under ~/.claude/skills/, - * so nested concretes were never listed in the Skill-tool available-skills list. - * Direct `Skill(skill="gsd-plan-phase")` calls stopped working. - * - * Fix: revert Claude to the FLAT layout — concrete skills at the top level: - * - * ~/.claude/skills/gsd-/SKILL.md - * - * The 6 ns-* routers are also top-level entries in the flat layout (they are - * concrete skills themselves). No nested skills/ subdirs for Claude. - * - * Other 6 runtimes (cline, qwen, hermes, augment, trae, antigravity) stay nested. - */ - -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -const { describe, test, before, after } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const os = require('node:os'); - -const ROOT = path.join(__dirname, '..'); -const COMMANDS_GSD = path.join(ROOT, 'commands', 'gsd'); - -const { installRuntimeArtifacts } = require('../gsd-core/bin/lib/install-engine.cjs'); -const { cleanup } = require('./helpers.cjs'); -const { - loadSkillsManifest, - resolveProfile, -} = require('../gsd-core/bin/lib/install-profiles.cjs'); -const { applySurface } = require('../gsd-core/bin/lib/surface.cjs'); -const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); - -const MANIFEST = loadSkillsManifest(COMMANDS_GSD); -const RESOLVED_FULL = resolveProfile({ modes: ['full'], manifest: MANIFEST }); - -// --------------------------------------------------------------------------- -// #924 regression: Claude global install must use FLAT layout -// --------------------------------------------------------------------------- - -describe('bug-924: claude global install uses flat skill layout (concrete skills discoverable)', () => { - let tmpDir; - - before(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-924-claude-flat-')); - installRuntimeArtifacts('claude', tmpDir, 'global', RESOLVED_FULL); - }); - - after(() => { - if (tmpDir) { - try { cleanup(tmpDir); } catch { /* best-effort */ } - } - }); - - test('claude global: concrete skills are at the TOP LEVEL of skills/ (flat, directly discoverable)', () => { - const skillsDir = path.join(tmpDir, 'skills'); - assert.ok(fs.existsSync(skillsDir), `skills/ dir must exist under ${tmpDir}`); - - const topLevel = fs.readdirSync(skillsDir).filter((n) => n.startsWith('gsd-')); - - // Flat layout must have MANY more than 6 top-level gsd-* entries (concrete skills). - // Pre-#924-fix nested layout had exactly 6 (only routers). Flat must have >= 60. - assert.ok( - topLevel.length >= 60, - `Claude global must have >= 60 gsd-* top-level skill dirs (concrete flat layout). ` + - `Got ${topLevel.length}: [${topLevel.slice(0, 10).join(', ')}${topLevel.length > 10 ? ', …' : ''}]. ` + - 'Nested layout detected — #924 regression: Claude must be flat.', - ); - }); - - test('claude global: gsd-plan-phase is directly at the top level of skills/', () => { - const skillsDir = path.join(tmpDir, 'skills'); - const planPhaseDir = path.join(skillsDir, 'gsd-plan-phase'); - assert.ok( - fs.existsSync(path.join(planPhaseDir, 'SKILL.md')), - `skills/gsd-plan-phase/SKILL.md must exist at top level for Claude global install. ` + - 'Concrete skill buried in nested layout — #924 regression.', - ); - }); - - test('claude global: gsd-execute-phase is directly at the top level of skills/', () => { - const skillsDir = path.join(tmpDir, 'skills'); - assert.ok( - fs.existsSync(path.join(skillsDir, 'gsd-execute-phase', 'SKILL.md')), - `skills/gsd-execute-phase/SKILL.md must exist at top level for Claude global install.`, - ); - }); - - test('claude global: gsd-code-review is directly at the top level of skills/', () => { - const skillsDir = path.join(tmpDir, 'skills'); - assert.ok( - fs.existsSync(path.join(skillsDir, 'gsd-code-review', 'SKILL.md')), - `skills/gsd-code-review/SKILL.md must exist at top level for Claude global install.`, - ); - }); - - test('claude global: gsd-ns-workflow is at the top level as a concrete skill (no nested skills/ subdir)', () => { - const skillsDir = path.join(tmpDir, 'skills'); - const nsWorkflowDir = path.join(skillsDir, 'gsd-ns-workflow'); - assert.ok( - fs.existsSync(path.join(nsWorkflowDir, 'SKILL.md')), - `skills/gsd-ns-workflow/SKILL.md must exist at top level (router as concrete skill).`, - ); - - // In the FLAT layout, gsd-ns-workflow/ must NOT have a skills/ subdir. - // A skills/ subdir means nested layout was applied (the #924 regression). - assert.ok( - !fs.existsSync(path.join(nsWorkflowDir, 'skills')), - `skills/gsd-ns-workflow/skills/ must NOT exist in flat layout (nested layout detected — #924 regression).`, - ); - }); - - test('claude global: no concrete skill is nested under gsd-ns-*/skills//SKILL.md', () => { - const skillsDir = path.join(tmpDir, 'skills'); - const topLevel = fs.readdirSync(skillsDir).filter((n) => n.startsWith('gsd-ns-')); - - for (const nsDir of topLevel) { - const nestedSkillsDir = path.join(skillsDir, nsDir, 'skills'); - assert.ok( - !fs.existsSync(nestedSkillsDir), - `${nsDir}/skills/ must NOT exist in Claude flat layout (#924 regression: nested layout detected).`, - ); - } - }); -}); - -// --------------------------------------------------------------------------- -// #924 regression: applySurface on Claude must also preserve flat layout -// (no re-nesting after surface update) -// --------------------------------------------------------------------------- - -describe('bug-924: applySurface on claude preserves flat layout (no re-nesting)', () => { - let tmpDir; - - before(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-924-surface-')); - installRuntimeArtifacts('claude', tmpDir, 'global', RESOLVED_FULL); - }); - - after(() => { - if (tmpDir) { - try { cleanup(tmpDir); } catch { /* best-effort */ } - } - }); - - test('claude: applySurface keeps concrete skills at the top level (flat, no re-nesting)', () => { - const skillsDir = path.join(tmpDir, 'skills'); - - // Sanity: install must produce flat layout (>= 60 top-level gsd-* dirs) - const topLevelAfterInstall = fs.readdirSync(skillsDir).filter((n) => n.startsWith('gsd-')); - assert.ok( - topLevelAfterInstall.length >= 60, - `Install must produce flat layout with >= 60 gsd-* dirs. Got ${topLevelAfterInstall.length}.`, - ); - - // Run applySurface (full surface → full profile) - const layout = resolveRuntimeArtifactLayout('claude', tmpDir, 'global'); - applySurface(tmpDir, layout, MANIFEST); - - // After applySurface: still flat - const topLevelAfterSurface = fs.readdirSync(skillsDir).filter((n) => n.startsWith('gsd-')); - assert.ok( - topLevelAfterSurface.length >= 60, - `After applySurface: must still have >= 60 gsd-* top-level dirs (flat). ` + - `Got ${topLevelAfterSurface.length}. Re-nesting detected.`, - ); - - // gsd-plan-phase must remain directly accessible - assert.ok( - fs.existsSync(path.join(skillsDir, 'gsd-plan-phase', 'SKILL.md')), - 'After applySurface: gsd-plan-phase/SKILL.md must remain at top level.', - ); - }); -}); diff --git a/tests/bug-950-quick-summary-status-complete.test.cjs b/tests/bug-950-quick-summary-status-complete.test.cjs deleted file mode 100644 index 1f079c999..000000000 --- a/tests/bug-950-quick-summary-status-complete.test.cjs +++ /dev/null @@ -1,301 +0,0 @@ -// allow-test-rule: source-text-is-the-product -/** - * Regression tests for bug #950 - * - * audit-open chronically flagged genuinely-complete quick tasks as [unknown] - * because NO shipped summary template carried a `status:` frontmatter field — - * so status was only emitted when the writing agent improvised it. - * - * The fix: add `status: complete` to all four summary templates and enforce it - * in the executor agent + quick.md workflow. Tests here exercise the scanner - * directly via auditOpenArtifacts() and also guard template text as a secondary - * contract check. - * - * Primary guard: behavioral audit-scanner tests (tasks read by scanQuickTasks) - * Secondary guard: template-contract text checks (template text IS the runtime contract) - * Writer-path guard: contract assertions on quick.md + gsd-executor.md (source-text-is-the-product) - */ - -'use strict'; - -const { describe, test, before, after } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const os = require('node:os'); - -const auditModule = require('../gsd-core/bin/lib/audit.cjs'); -const { auditOpenArtifacts } = auditModule; -const { cleanup } = require('./helpers.cjs'); - -const TEMPLATES_DIR = path.resolve(__dirname, '..', 'gsd-core', 'templates'); -const QUICK_MD = path.resolve(__dirname, '..', 'gsd-core', 'workflows', 'quick.md'); -const EXECUTOR_MD = path.resolve(__dirname, '..', 'agents', 'gsd-executor.md'); - -function mkTmp() { - return fs.mkdtempSync(path.join(os.tmpdir(), 'bug-950-')); -} - -/** - * Extract the first YAML frontmatter block from a file's content. - * - * Two layouts are handled: - * - Leading frontmatter: file starts with `---\n…\n---` (summary-minimal/standard/complex.md) - * - Fenced frontmatter: frontmatter lives inside a ```markdown … ``` fence (summary.md, - * whose content IS a markdown example showing the template). In that case we extract - * the `---\n…\n---` block that sits immediately after the opening fence line. - * - * Returns the raw text of the YAML block (between the two `---` delimiters, exclusive), - * or null if no frontmatter could be found. - */ -function extractFrontmatter(content) { - // Case 1: file begins with --- (leading frontmatter) - if (/^---\r?\n/.test(content)) { - const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/); - return match ? match[1] : null; - } - - // Case 2: frontmatter is embedded inside a fenced block (```markdown\n---\n…\n---\n) - const fenceMatch = content.match(/```(?:markdown|md)?\r?\n(---\r?\n[\s\S]*?\r?\n---)\r?\n/); - if (fenceMatch) { - // Strip the outer --- delimiters to get just the YAML body - const block = fenceMatch[1]; - const inner = block.match(/^---\r?\n([\s\S]*?)\r?\n---$/); - return inner ? inner[1] : null; - } - - return null; -} - -describe('bug #950: quick-task SUMMARY must carry status: complete', () => { - // Ensure GSD env vars do not redirect planningDir() away from our fixture. - let prevProject, prevWorkstream; - before(() => { - prevProject = process.env.GSD_PROJECT; - prevWorkstream = process.env.GSD_WORKSTREAM; - delete process.env.GSD_PROJECT; - delete process.env.GSD_WORKSTREAM; - }); - after(() => { - if (prevProject !== undefined) process.env.GSD_PROJECT = prevProject; - if (prevWorkstream !== undefined) process.env.GSD_WORKSTREAM = prevWorkstream; - }); - - // ── Behavioral: scanner recognizes complete quick tasks ─────────────────── - - test('[PRIMARY] quick task SUMMARY with status: complete is NOT flagged open', () => { - // Simulates an executor that correctly wrote the SUMMARY with status: complete - // (as required after the fix). The scanner must report 0 open quick tasks. - const cwd = mkTmp(); - try { - const quickId = '260609-test-status-complete'; - const taskDir = path.join(cwd, '.planning', 'quick', quickId); - fs.mkdirSync(taskDir, { recursive: true }); - fs.writeFileSync( - path.join(taskDir, `${quickId}-SUMMARY.md`), - [ - '---', - 'status: complete', - 'date: 2026-06-09', - 'slug: test-status-complete', - '---', - '', - '# Quick Task Summary', - '', - 'Task completed successfully.', - ].join('\n'), - 'utf-8' - ); - - const result = auditOpenArtifacts(cwd); - const realQuickTasks = result.items.quick_tasks.filter( - i => !i.scan_error && !i._remainder_count - ); - - assert.equal( - realQuickTasks.length, - 0, - `quick task SUMMARY with status: complete must NOT appear as open; ` + - `got: ${JSON.stringify(realQuickTasks)}` - ); - assert.equal(result.counts.quick_tasks, 0); - } finally { - cleanup(cwd); - } - }); - - test('[PRIMARY] quick task SUMMARY without status: field is still flagged [unknown]', () => { - // Negative case: a SUMMARY that lacks status: still surfaces as [unknown]. - // This proves the scanner still catches real gaps — the fix must be on the writer side. - const cwd = mkTmp(); - try { - const quickId = '260609-test-no-status'; - const taskDir = path.join(cwd, '.planning', 'quick', quickId); - fs.mkdirSync(taskDir, { recursive: true }); - fs.writeFileSync( - path.join(taskDir, `${quickId}-SUMMARY.md`), - [ - '---', - 'date: 2026-06-09', - 'slug: test-no-status', - '---', - '', - '# Quick Task Summary', - '', - 'Task done, but no status field.', - ].join('\n'), - 'utf-8' - ); - - const result = auditOpenArtifacts(cwd); - const realQuickTasks = result.items.quick_tasks.filter( - i => !i.scan_error && !i._remainder_count - ); - - assert.equal( - realQuickTasks.length, - 1, - `quick task SUMMARY without status: must appear as open (unknown); ` + - `got: ${JSON.stringify(realQuickTasks)}` - ); - assert.equal(realQuickTasks[0].status, 'unknown', 'expected status to be unknown'); - } finally { - cleanup(cwd); - } - }); - - test('[PRIMARY] quick task without any SUMMARY is still flagged [missing]', () => { - // Proves the missing-SUMMARY case still surfaces. - const cwd = mkTmp(); - try { - const quickId = '260609-test-missing-summary'; - const taskDir = path.join(cwd, '.planning', 'quick', quickId); - fs.mkdirSync(taskDir, { recursive: true }); - // No SUMMARY file at all. - - const result = auditOpenArtifacts(cwd); - const realQuickTasks = result.items.quick_tasks.filter( - i => !i.scan_error && !i._remainder_count - ); - - assert.equal(realQuickTasks.length, 1, 'missing SUMMARY must still be flagged'); - assert.equal(realQuickTasks[0].status, 'missing'); - } finally { - cleanup(cwd); - } - }); - - test('[PRIMARY] SUMMARY with status: COMPLETE (uppercase) is also recognized', () => { - // Scanner lowercases before comparing — verify case-insensitivity holds. - const cwd = mkTmp(); - try { - const quickId = '260609-test-uppercase-complete'; - const taskDir = path.join(cwd, '.planning', 'quick', quickId); - fs.mkdirSync(taskDir, { recursive: true }); - fs.writeFileSync( - path.join(taskDir, `${quickId}-SUMMARY.md`), - '---\nstatus: COMPLETE\n---\n# Summary\nDone.\n', - 'utf-8' - ); - - const result = auditOpenArtifacts(cwd); - const realQuickTasks = result.items.quick_tasks.filter( - i => !i.scan_error && !i._remainder_count - ); - - assert.equal( - realQuickTasks.length, - 0, - `quick task SUMMARY with status: COMPLETE (uppercase) must not appear as open; ` + - `got: ${JSON.stringify(realQuickTasks)}` - ); - } finally { - cleanup(cwd); - } - }); - - // ── Secondary: template-contract checks ────────────────────────────────── - // Assertions are scoped to the actual YAML frontmatter block, not the whole file, - // so a stray `status: complete` in prose or examples cannot produce a false green. - - test('[TEMPLATE CONTRACT] summary.md contains status: complete in frontmatter', () => { - // summary.md is a documentation template — its frontmatter lives inside a - // ```markdown fence. extractFrontmatter() finds and returns that block. - const content = fs.readFileSync(path.join(TEMPLATES_DIR, 'summary.md'), 'utf-8'); - const fm = extractFrontmatter(content); - assert.ok( - fm !== null, - 'gsd-core/templates/summary.md: could not locate a YAML frontmatter block (leading --- or fenced ```markdown --- block)' - ); - assert.ok( - /^status:\s*complete\s*$/m.test(fm), - `gsd-core/templates/summary.md: \`status: complete\` not found in the frontmatter block.\n` + - `Frontmatter extracted:\n${fm}` - ); - }); - - test('[TEMPLATE CONTRACT] summary-minimal.md contains status: complete in frontmatter', () => { - const content = fs.readFileSync(path.join(TEMPLATES_DIR, 'summary-minimal.md'), 'utf-8'); - const fm = extractFrontmatter(content); - assert.ok( - fm !== null, - 'gsd-core/templates/summary-minimal.md: could not locate a leading YAML frontmatter block' - ); - assert.ok( - /^status:\s*complete\s*$/m.test(fm), - `gsd-core/templates/summary-minimal.md: \`status: complete\` not found in the frontmatter block.\n` + - `Frontmatter extracted:\n${fm}` - ); - }); - - test('[TEMPLATE CONTRACT] summary-standard.md contains status: complete in frontmatter', () => { - const content = fs.readFileSync(path.join(TEMPLATES_DIR, 'summary-standard.md'), 'utf-8'); - const fm = extractFrontmatter(content); - assert.ok( - fm !== null, - 'gsd-core/templates/summary-standard.md: could not locate a leading YAML frontmatter block' - ); - assert.ok( - /^status:\s*complete\s*$/m.test(fm), - `gsd-core/templates/summary-standard.md: \`status: complete\` not found in the frontmatter block.\n` + - `Frontmatter extracted:\n${fm}` - ); - }); - - test('[TEMPLATE CONTRACT] summary-complex.md contains status: complete in frontmatter', () => { - const content = fs.readFileSync(path.join(TEMPLATES_DIR, 'summary-complex.md'), 'utf-8'); - const fm = extractFrontmatter(content); - assert.ok( - fm !== null, - 'gsd-core/templates/summary-complex.md: could not locate a leading YAML frontmatter block' - ); - assert.ok( - /^status:\s*complete\s*$/m.test(fm), - `gsd-core/templates/summary-complex.md: \`status: complete\` not found in the frontmatter block.\n` + - `Frontmatter extracted:\n${fm}` - ); - }); - - // ── Writer-path contract checks ─────────────────────────────────────────── - // Guards quick.md and gsd-executor.md so a future edit removing `status: complete` - // from the SUMMARY-creation instructions would fail the suite before the bug recurs. - // (source-text-is-the-product: the deployed .md text IS the runtime contract for agents) - - test('[WRITER-PATH] quick.md constraints require status: complete in SUMMARY frontmatter', () => { - const content = fs.readFileSync(QUICK_MD, 'utf-8'); - assert.ok( - /status:\s*complete/.test(content), - 'gsd-core/workflows/quick.md must instruct the executor to write `status: complete` in the SUMMARY frontmatter. ' + - 'The block must contain the `status: complete` requirement so a future edit cannot silently drop it.' - ); - }); - - test('[WRITER-PATH] gsd-executor.md frontmatter spec requires status: complete', () => { - const content = fs.readFileSync(EXECUTOR_MD, 'utf-8'); - assert.ok( - /status[\s\S]{0,40}complete/.test(content), - 'agents/gsd-executor.md must document `status: complete` as a required SUMMARY frontmatter field. ' + - 'The Frontmatter section must include `status: complete` so the executor always emits it.' - ); - }); -}); diff --git a/tests/bug-974-graphify-budget-missing-value.test.cjs b/tests/bug-974-graphify-budget-missing-value.test.cjs deleted file mode 100644 index b86578403..000000000 --- a/tests/bug-974-graphify-budget-missing-value.test.cjs +++ /dev/null @@ -1,362 +0,0 @@ -'use strict'; -/** - * bug-974-graphify-budget-missing-value.test.cjs - * - * Regression guard: `gsd-tools graphify query --budget` with no value - * following --budget must emit a USAGE error and exit non-zero. - * - * Bug: args[budgetIdx + 1] was undefined → parseInt(undefined, 10) → NaN. - * NaN is falsy so the budget guard `if (options.budget)` silently skipped - * trimming and the query ran unbounded with no warning. (#974) - * - * Test categories: - * 1. BEHAVIORAL (recording mock) — direct routeGraphifyCommand call, no I/O - * 2. SUBPROCESS — end-to-end via runGsdTools - * 3. PROPERTY — budget arg parser: non-numeric/absent → usage error, valid int → passes - */ - -const { describe, test, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const path = require('node:path'); - -const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); -const { - enableGraphify, - writeGraphJson, - SAMPLE_GRAPH, -} = require('./helpers/graphify.cjs'); -const fc = require('./helpers/fast-check-setup.cjs'); - -const { routeGraphifyCommand } = require('../gsd-core/bin/lib/graphify-command-router.cjs'); - -// ─── Recording-mock helpers (mirrors graphify-command-cutover.test.cjs) ──────── - -function makeGraphifyMock() { - const calls = []; - function recorder(name, ...fnArgs) { - const sentinel = { _mock: name, args: fnArgs }; - calls.push(sentinel); - return sentinel; - } - return { - calls, - mock: { - graphifyQuery: (cwd, term, opts) => recorder('graphifyQuery', cwd, term, opts), - graphifyStatus: (cwd) => recorder('graphifyStatus', cwd), - graphifyDiff: (cwd) => recorder('graphifyDiff', cwd), - graphifyBuild: (cwd) => recorder('graphifyBuild', cwd), - writeSnapshot: (cwd) => recorder('writeSnapshot', cwd), - }, - }; -} - -function makeErrorRecorder() { - const calls = []; - const fn = (msg, reason) => calls.push({ msg, reason }); - fn.calls = calls; - return fn; -} - -function runJsonErrors(args, tmpDir, env = {}) { - const result = runGsdTools(args, tmpDir, { ...env, GSD_JSON_ERRORS: '1' }); - assert.strictEqual(result.success, false, - `Expected failure with GSD_JSON_ERRORS=1 for args: ${args.join(' ')}\n` + - `stdout: ${result.output}\nstderr: ${result.error}`); - let parsed; - try { - parsed = JSON.parse(result.error); - } catch (e) { - throw new Error( - `GSD_JSON_ERRORS=1 must emit valid JSON on stderr.\n` + - `Args: ${args.join(' ')}\nstderr: ${result.error}\nparse error: ${e.message}`, - ); - } - return parsed; -} - -function assertTypedError(parsed, expectedReason, label) { - assert.strictEqual(parsed.ok, false, `${label}: error object must have ok: false`); - assert.strictEqual(parsed.reason, expectedReason, - `${label}: reason must be "${expectedReason}", got: ${parsed.reason}`); - assert.ok(typeof parsed.message === 'string' && parsed.message.length > 0, - `${label}: message must be a non-empty string`); -} - -// ─── 1. BEHAVIORAL — direct routeGraphifyCommand (recording mock, no I/O) ───── - -describe('bug #974: --budget missing value → usage error (unit, recording mock)', () => { - const CWD = '/fake/cwd'; - const RAW = false; - - // (a) --budget as last arg (no value following) - test('(a) --budget last arg (missing value) → error(USAGE); graphifyQuery NOT called', () => { - const { calls, mock } = makeGraphifyMock(); - const errFn = makeErrorRecorder(); - routeGraphifyCommand({ - args: ['graphify', 'query', 'myterm', '--budget'], - cwd: CWD, raw: RAW, error: errFn, _graphify: mock, - }); - assert.strictEqual(errFn.calls.length, 1, - `error must be called exactly once; got ${errFn.calls.length} calls`); - assert.strictEqual(errFn.calls[0].reason, 'usage', - `reason must be 'usage'; got: ${errFn.calls[0].reason}`); - assert.ok( - errFn.calls[0].msg.includes('graphify query'), - `usage message must mention "graphify query"; got: ${errFn.calls[0].msg}`, - ); - assert.strictEqual(calls.length, 0, - 'graphifyQuery must NOT be called when --budget has no value'); - }); - - // (b) --budget with a non-numeric value - test('(b) --budget foo (non-numeric value) → error(USAGE); graphifyQuery NOT called', () => { - const { calls, mock } = makeGraphifyMock(); - const errFn = makeErrorRecorder(); - routeGraphifyCommand({ - args: ['graphify', 'query', 'myterm', '--budget', 'foo'], - cwd: CWD, raw: RAW, error: errFn, _graphify: mock, - }); - assert.strictEqual(errFn.calls.length, 1, - `error must be called exactly once; got ${errFn.calls.length} calls`); - assert.strictEqual(errFn.calls[0].reason, 'usage', - `reason must be 'usage'; got: ${errFn.calls[0].reason}`); - assert.strictEqual(calls.length, 0, - 'graphifyQuery must NOT be called when --budget value is non-numeric'); - }); - - // (c) --budget 0 → 0 is a valid integer, must NOT error - test('(c) --budget 0 (explicit zero) → budget passed as 0, no error', () => { - const { calls, mock } = makeGraphifyMock(); - const errFn = makeErrorRecorder(); - routeGraphifyCommand({ - args: ['graphify', 'query', 'myterm', '--budget', '0'], - cwd: CWD, raw: RAW, error: errFn, _graphify: mock, - }); - assert.strictEqual(errFn.calls.length, 0, - `error must NOT be called for --budget 0; got: ${JSON.stringify(errFn.calls)}`); - assert.strictEqual(calls.length, 1, - 'graphifyQuery MUST be called for --budget 0'); - assert.strictEqual(calls[0]._mock, 'graphifyQuery'); - assert.strictEqual(calls[0].args[2].budget, 0, - `budget must be 0, got: ${calls[0].args[2].budget}`); - }); - - // (d) valid --budget 500 → regression: still works after fix - test('(d) --budget 500 (valid positive integer) → budget: 500, no error', () => { - const { calls, mock } = makeGraphifyMock(); - const errFn = makeErrorRecorder(); - routeGraphifyCommand({ - args: ['graphify', 'query', 'myterm', '--budget', '500'], - cwd: CWD, raw: RAW, error: errFn, _graphify: mock, - }); - assert.strictEqual(errFn.calls.length, 0, - `error must NOT be called for --budget 500; got: ${JSON.stringify(errFn.calls)}`); - assert.strictEqual(calls.length, 1); - assert.strictEqual(calls[0]._mock, 'graphifyQuery'); - assert.strictEqual(calls[0].args[2].budget, 500, - `budget must be integer 500; got: ${calls[0].args[2].budget}`); - assert.strictEqual(typeof calls[0].args[2].budget, 'number', - 'budget must be a number, not string'); - }); - -}); - -// ─── 2. SUBPROCESS — end-to-end via runGsdTools ─────────────────────────────── - -describe('bug #974: --budget missing value → usage error (subprocess)', () => { - let tmpDir; - let planningDir; - - beforeEach(() => { - tmpDir = createTempProject(); - planningDir = path.join(tmpDir, '.planning'); - enableGraphify(planningDir); - writeGraphJson(planningDir, SAMPLE_GRAPH); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('subprocess: --budget last arg → non-zero exit', () => { - const result = runGsdTools( - ['graphify', 'query', 'AuthService', '--budget'], - tmpDir, - ); - assert.strictEqual(result.success, false, - `--budget with no value must exit non-zero; stderr: ${result.error}`); - }); - - test('subprocess: --budget last arg → stderr contains usage hint', () => { - const result = runGsdTools( - ['graphify', 'query', 'AuthService', '--budget'], - tmpDir, - ); - assert.ok( - result.error.includes('Usage') || result.error.includes('graphify query'), - `stderr must contain usage hint; got: ${result.error}`, - ); - }); - - test('subprocess: --budget foo (non-numeric) → non-zero exit', () => { - const result = runGsdTools( - ['graphify', 'query', 'AuthService', '--budget', 'foo'], - tmpDir, - ); - assert.strictEqual(result.success, false, - `--budget with non-numeric value must exit non-zero; stderr: ${result.error}`); - }); - - test('subprocess: --budget last arg → GSD_JSON_ERRORS=1 → usage reason', () => { - const parsed = runJsonErrors( - ['graphify', 'query', 'AuthService', '--budget'], - tmpDir, - ); - assertTypedError(parsed, 'usage', '--budget missing value json-error'); - assert.ok( - parsed.message.includes('graphify query'), - `message must include "graphify query"; got: ${parsed.message}`, - ); - }); - - test('subprocess: --budget foo → GSD_JSON_ERRORS=1 → usage reason', () => { - const parsed = runJsonErrors( - ['graphify', 'query', 'AuthService', '--budget', 'foo'], - tmpDir, - ); - assertTypedError(parsed, 'usage', '--budget non-numeric json-error'); - }); - - // Regression: valid --budget still works after the fix - test('subprocess: --budget 500 (valid) → success, term echoed', () => { - const result = runGsdTools( - ['graphify', 'query', 'AuthService', '--budget', '500'], - tmpDir, - ); - assert.ok(result.success, - `--budget 500 must still succeed after fix; error: ${result.error}`); - const parsed = JSON.parse(result.output); - assert.strictEqual(parsed.term, 'AuthService', - 'term must be echoed in query response'); - assert.ok('nodes' in parsed, 'response must include nodes array'); - assert.ok('total_nodes' in parsed, 'response must include total_nodes field'); - }); -}); - -// ─── 3. PROPERTY — budget parse: non-numeric/absent → usage error, valid int → passes -// Per RULESET.TESTS.property-based-testing: parsing/budget-limit modules -// must include ≥1 fast-check property test. - -describe('bug #974: budget arg parser property tests', () => { - const CWD = '/fake/cwd'; - const RAW = false; - - // Property (a): any non-numeric, non-parseable string for --budget → usage error - // - // The budget-VALUE generator is constrained to genuinely non-numeric values. - // The .filter(v => Number.isNaN(parseInt(v, 10))) at the end is the authoritative - // gate: it excludes any value that parseInt(v, 10) would accept (e.g. "+5", "-3", - // " 7", "0x1a", "007"). This makes the property universally true rather than - // relying on a runtime skip inside the property body. - // The term is fixed as "myterm" — this property is about the BUDGET VALUE, not - // the term. - test('property: non-numeric --budget value always produces usage error', () => { - fc.assert( - fc.property( - fc.oneof( - // Alphabetic-start strings (parseInt('abc') = NaN) - fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9_-]{0,20}$/), - // Empty string (parseInt('') = NaN) - fc.constant(''), - // Strings starting with special chars — filtered below for safety - fc.stringMatching(/^[!@#$%^&*()_+={}\][|\\:;"'<,>?/~`][^\s]{0,10}$/), - ).filter(v => Number.isNaN(parseInt(v, 10))), - (badValue) => { - const { mock } = makeGraphifyMock(); - const errFn = makeErrorRecorder(); - routeGraphifyCommand({ - args: ['graphify', 'query', 'myterm', '--budget', badValue], - cwd: CWD, raw: RAW, error: errFn, _graphify: mock, - }); - assert.strictEqual(errFn.calls.length, 1, - `--budget "${badValue}" (NaN) must produce exactly one error call`); - assert.strictEqual(errFn.calls[0].reason, 'usage', - `--budget "${badValue}" error must have reason 'usage'; got: ${errFn.calls[0].reason}`); - }, - ), - // Explicit seed + numRuns for CI determinism. - { seed: 1001, numRuns: 200 }, - ); - }); - - // Example (b): --budget as last arg (missing value) → usage error, for fixed valid terms. - // Previously a property fuzz over a validTerm generator; replaced with deterministic - // examples because the regex `/^[A-Za-z0-9][A-Za-z0-9_.-]{0,29}$/` admitted single - // digits like "0" which are falsy and trigger the router's `if (!term)` guard instead - // of the budget-missing-value path, causing a spurious test failure (seed 1004, term - // "0"). These properties are about the BUDGET contract; the term is a fixed bystander. - test('example: --budget as last arg always produces usage error (fixed terms)', () => { - const FIXED_TERMS = ['someterm', 'AuthService', 'a1', 'graph-node_1', 'MyComponent']; - for (const term of FIXED_TERMS) { - const { mock } = makeGraphifyMock(); - const errFn = makeErrorRecorder(); - routeGraphifyCommand({ - args: ['graphify', 'query', term, '--budget'], - cwd: CWD, raw: RAW, error: errFn, _graphify: mock, - }); - assert.strictEqual(errFn.calls.length, 1, - `--budget as last arg: term=${JSON.stringify(term)}: must produce exactly one error call; got ${errFn.calls.length}`); - assert.strictEqual(errFn.calls[0].reason, 'usage', - `--budget as last arg: term=${JSON.stringify(term)}: reason must be 'usage'; got ${errFn.calls[0].reason}`); - } - }); - - // Property (c): valid positive integer strings → budget passed through as number, no error - test('property: positive integer string for --budget is always accepted', () => { - fc.assert( - fc.property( - fc.integer({ min: 1, max: 10_000_000 }), - (n) => { - const { calls, mock } = makeGraphifyMock(); - const errFn = makeErrorRecorder(); - routeGraphifyCommand({ - args: ['graphify', 'query', 'myterm', '--budget', String(n)], - cwd: CWD, raw: RAW, error: errFn, _graphify: mock, - }); - assert.strictEqual(errFn.calls.length, 0, - `--budget ${n} (valid positive integer) must not produce an error`); - assert.strictEqual(calls.length, 1, 'graphifyQuery must be called'); - assert.strictEqual(calls[0].args[2].budget, n, - `budget must equal ${n}; got: ${calls[0].args[2].budget}`); - assert.strictEqual(typeof calls[0].args[2].budget, 'number', - `budget must be a number, not string "${calls[0].args[2].budget}"`); - }, - ), - // Explicit seed + numRuns for CI determinism. - { seed: 1003, numRuns: 200 }, - ); - }); - - // Example (d): absence of --budget flag yields budget:null and no error, for fixed valid terms. - // Previously a property fuzz over a validTerm generator; replaced with deterministic - // examples for the same reason as example (b): single-digit terms like "0" are falsy - // and trigger the router's term-missing usage error, not the budget-absent path. - // These examples fix the term as clearly valid multi-char identifiers. - test('example: absence of --budget flag yields budget:null and no error (fixed terms)', () => { - const FIXED_TERMS = ['someterm', 'AuthService', 'a1', 'graph-node_1', 'MyComponent']; - for (const term of FIXED_TERMS) { - const { calls, mock } = makeGraphifyMock(); - const errFn = makeErrorRecorder(); - routeGraphifyCommand({ - args: ['graphify', 'query', term], - cwd: CWD, raw: RAW, error: errFn, _graphify: mock, - }); - assert.strictEqual(errFn.calls.length, 0, - `no --budget flag must not produce an error for term "${term}"`); - assert.strictEqual(calls.length, 1, - `graphifyQuery must be called for term "${term}"; got calls.length=${calls.length}`); - assert.strictEqual(calls[0].args[2].budget, null, - `absent --budget must pass budget:null; got: ${calls[0].args[2].budget}`); - } - }); -}); diff --git a/tests/bug-kimi-path-layout-local-guard.test.cjs b/tests/bug-kimi-path-layout-local-guard.test.cjs deleted file mode 100644 index 8396358b9..000000000 --- a/tests/bug-kimi-path-layout-local-guard.test.cjs +++ /dev/null @@ -1,423 +0,0 @@ -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const os = require('node:os'); -const path = require('node:path'); -const { spawnSync } = require('node:child_process'); - -const { cleanup } = require('./helpers.cjs'); -const { installerEnv } = require('./helpers/install-shared.cjs'); - -const ROOT = path.join(__dirname, '..'); - -// Cross-platform temp paths for test fixtures (avoids hardcoded /tmp) -const KIMI_CFG = path.join(os.tmpdir(), 'gsd-kimi-config-test').replace(/\\/g, '/'); -const XDG_HOME = path.join(os.tmpdir(), 'gsd-xdg-home-test'); -const INSTALL_SCRIPT = path.join(ROOT, 'bin', 'install.js'); - -const { - getGlobalConfigDir, - getGlobalSkillsBase, - getGlobalSkillDir, - resolveKimiGlobalDir, -} = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-homes.cjs')); -const { - resolveRuntimeArtifactLayout, -} = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-artifact-layout.cjs')); -const { - getGlobalDir, - getConfigDirFromHome, -} = require('../bin/install.js'); - -function withEnv(updates, fn) { - const saved = {}; - for (const key of Object.keys(updates)) { - saved[key] = process.env[key]; - const value = updates[key]; - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } - try { - return fn(); - } finally { - for (const key of Object.keys(updates)) { - if (saved[key] === undefined) delete process.env[key]; - else process.env[key] = saved[key]; - } - } -} - -describe('Kimi runtime homes', () => { - test('default Kimi global root is recommended ~/.config/agents when no generic skills root exists', () => { - const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-home-default-')); - try { - withEnv({ KIMI_CONFIG_DIR: undefined, XDG_CONFIG_HOME: undefined, HOME: tmpHome, USERPROFILE: tmpHome }, () => { - assert.strictEqual( - getGlobalConfigDir('kimi'), - path.join(tmpHome, '.config', 'agents'), - ); - assert.strictEqual( - getGlobalSkillsBase('kimi'), - path.join(tmpHome, '.config', 'agents', 'skills'), - ); - assert.strictEqual( - getGlobalSkillDir('kimi', 'gsd-help'), - path.join(tmpHome, '.config', 'agents', 'skills', 'gsd-help'), - ); - assert.notStrictEqual( - getGlobalSkillsBase('kimi'), - path.join(tmpHome, '.kimi-code', 'skills'), - ); - }); - } finally { - cleanup(tmpHome); - } - }); - - test('Kimi root resolution follows first-existing generic skills directory order', () => { - const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-home-existing-')); - try { - const recommendedRoot = path.join(tmpHome, '.config', 'agents'); - const fallbackRoot = path.join(tmpHome, '.agents'); - assert.strictEqual( - resolveKimiGlobalDir({ env: {}, home: tmpHome, existsSync: fs.existsSync }), - recommendedRoot, - ); - - fs.mkdirSync(path.join(fallbackRoot, 'skills'), { recursive: true }); - assert.strictEqual( - resolveKimiGlobalDir({ env: {}, home: tmpHome, existsSync: fs.existsSync }), - fallbackRoot, - ); - - fs.mkdirSync(path.join(recommendedRoot, 'skills'), { recursive: true }); - assert.strictEqual( - resolveKimiGlobalDir({ env: {}, home: tmpHome, existsSync: fs.existsSync }), - recommendedRoot, - ); - } finally { - cleanup(tmpHome); - } - }); - - test('KIMI_CONFIG_DIR can select the brand-specific ~/.kimi-code root', () => { - const customKimiDir = path.join(os.tmpdir(), 'custom-kimi-code'); - withEnv({ KIMI_CONFIG_DIR: customKimiDir, XDG_CONFIG_HOME: undefined }, () => { - assert.strictEqual(String(getGlobalConfigDir('kimi')).replace(/\\/g, '/'), customKimiDir.replace(/\\/g, '/')); - assert.strictEqual( - getGlobalSkillsBase('kimi'), - path.join(customKimiDir, 'skills'), - ); - assert.strictEqual(String(getGlobalDir('kimi')).replace(/\\/g, '/'), customKimiDir.replace(/\\/g, '/')); - }); - }); - - test('XDG_CONFIG_HOME does not change Kimi default root', () => { - const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-home-xdg-')); - try { - withEnv({ KIMI_CONFIG_DIR: undefined, XDG_CONFIG_HOME: XDG_HOME, HOME: tmpHome, USERPROFILE: tmpHome }, () => { - assert.strictEqual( - getGlobalConfigDir('kimi'), - path.join(tmpHome, '.config', 'agents'), - ); - assert.strictEqual( - getConfigDirFromHome('kimi', true), - "'.config', 'agents'", - ); - }); - } finally { - cleanup(tmpHome); - } - }); - - test('Kimi global install reuses existing ~/.agents/skills when recommended root is absent', () => { - const tmpProject = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-existing-agents-project-')); - const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-existing-agents-home-')); - try { - fs.mkdirSync(path.join(tmpHome, '.agents', 'skills'), { recursive: true }); - const result = spawnSync( - process.execPath, - [INSTALL_SCRIPT, '--kimi', '--global', '--no-sdk'], - { - cwd: tmpProject, - encoding: 'utf8', - env: installerEnv({ HOME: tmpHome, USERPROFILE: tmpHome }), - }, - ); - - assert.strictEqual( - result.status, - 0, - `expected --kimi --global to reuse existing ~/.agents/skills\nstdout: ${result.stdout}\nstderr: ${result.stderr}`, - ); - assert.strictEqual( - fs.existsSync(path.join(tmpHome, '.agents', 'skills', 'gsd-new-project', 'SKILL.md')), - true, - ); - assert.strictEqual( - fs.existsSync(path.join(tmpHome, '.config', 'agents', 'skills', 'gsd-new-project', 'SKILL.md')), - false, - ); - } finally { - cleanup(tmpProject); - cleanup(tmpHome); - } - }); -}); - -describe('Kimi runtime artifact layout', () => { - test('global layout stages Kimi skills and agents while local layout remains guarded', () => { - const globalLayout = resolveRuntimeArtifactLayout('kimi', KIMI_CFG, 'global'); - assert.strictEqual(globalLayout.runtime, 'kimi'); - assert.strictEqual(String(globalLayout.configDir).replace(/\\/g, '/'), KIMI_CFG); - assert.strictEqual(globalLayout.kinds.length, 2); - assert.strictEqual(globalLayout.kinds[0].kind, 'skills'); - assert.strictEqual(globalLayout.kinds[0].destSubpath, 'skills'); - assert.strictEqual(globalLayout.kinds[0].prefix, 'gsd-'); - assert.strictEqual(typeof globalLayout.kinds[0].stage, 'function'); - assert.strictEqual(globalLayout.kinds[1].kind, 'kimi-agents'); - assert.strictEqual(globalLayout.kinds[1].destSubpath, 'agents'); - assert.strictEqual(globalLayout.kinds[1].prefix, 'gsd'); - assert.strictEqual(typeof globalLayout.kinds[1].stage, 'function'); - - const localLayout = resolveRuntimeArtifactLayout('kimi', KIMI_CFG, 'local'); - assert.strictEqual(localLayout.runtime, 'kimi'); - assert.deepStrictEqual(localLayout.kinds, []); - }); -}); - -describe('Kimi local install guard', () => { - test('--kimi --local exits successfully without writing local Kimi project artifacts', () => { - const tmpProject = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-local-project-')); - const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-local-home-')); - try { - const result = spawnSync( - process.execPath, - [INSTALL_SCRIPT, '--kimi', '--local', '--no-sdk'], - { - cwd: tmpProject, - encoding: 'utf8', - env: installerEnv({ HOME: tmpHome, USERPROFILE: tmpHome }), - }, - ); - - assert.strictEqual( - result.status, - 0, - `expected --kimi --local guard to no-op successfully\nstdout: ${result.stdout}\nstderr: ${result.stderr}`, - ); - const combined = `${result.stdout}\n${result.stderr}`; - assert.match(combined, /Kimi local install/i); - assert.match(combined, /deferred/i); - - assert.ok(!fs.existsSync(path.join(tmpProject, '.kimi')), 'must not create legacy .kimi/'); - assert.ok(!fs.existsSync(path.join(tmpProject, '.kimi-code')), 'must not create .kimi-code/'); - assert.ok(!fs.existsSync(path.join(tmpProject, '.agents')), 'must not create .agents/'); - assert.ok(!fs.existsSync(path.join(tmpProject, '.claude')), 'must not fall back to Claude local install'); - } finally { - cleanup(tmpProject); - cleanup(tmpHome); - } - }); - - test('--kimi --global writes converted Kimi skills and agent artifacts', () => { - const tmpProject = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-global-project-')); - const tmpConfig = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-global-config-')); - const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-global-home-')); - try { - const result = spawnSync( - process.execPath, - [INSTALL_SCRIPT, '--kimi', '--global', '--config-dir', tmpConfig, '--no-sdk'], - { - cwd: tmpProject, - encoding: 'utf8', - env: installerEnv({ HOME: tmpHome, USERPROFILE: tmpHome }), - }, - ); - - assert.strictEqual( - result.status, - 0, - `expected --kimi --global to install Kimi skills successfully\nstdout: ${result.stdout}\nstderr: ${result.stderr}`, - ); - const combined = `${result.stdout}\n${result.stderr}`; - assert.match(combined, /Installing for .*Kimi/i); - assert.match(combined, /Installed \d+ Kimi skills to skills\//i); - assert.match(combined, /Generated Kimi root agent: .*agents.*gsd\.yaml/i); - assert.match(combined, /kimi --agent-file/i); - assert.match(combined, /Wrote file manifest/i); - - const skillFile = path.join(tmpConfig, 'skills', 'gsd-new-project', 'SKILL.md'); - assert.ok(fs.existsSync(skillFile), 'must write gsd-new-project/SKILL.md'); - const skillContent = fs.readFileSync(skillFile, 'utf8'); - assert.match(skillContent, /^name: gsd-new-project$/m); - assert.match(skillContent, /\/skill:gsd-new-project/); - assert.match(skillContent, /gsd-core\/workflows\/new-project\.md/); - assert.doesNotMatch(skillContent, /@~\/\.claude\/gsd-core|@\$HOME\/\.claude\/gsd-core/); - assert.doesNotMatch(skillContent, /@[^\r\n]*\\/, 'serialized Kimi payload references must use forward slashes'); - assert.doesNotMatch(skillContent, /kimi_cli\.tools|system_prompt_path|^version: 1$/m); - - const rootYaml = path.join(tmpConfig, 'agents', 'gsd.yaml'); - const rootPrompt = path.join(tmpConfig, 'agents', 'gsd.md'); - const executorYaml = path.join(tmpConfig, 'agents', 'subagents', 'gsd-executor.yaml'); - const executorPrompt = path.join(tmpConfig, 'agents', 'subagents', 'gsd-executor.md'); - assert.ok(fs.existsSync(rootYaml), 'must write agents/gsd.yaml'); - assert.ok(fs.existsSync(rootPrompt), 'must write agents/gsd.md'); - assert.ok(fs.existsSync(executorYaml), 'must write agents/subagents/gsd-executor.yaml'); - assert.ok(fs.existsSync(executorPrompt), 'must write agents/subagents/gsd-executor.md'); - - const rootYamlContent = fs.readFileSync(rootYaml, 'utf8'); - assert.match(rootYamlContent, /^version: 1$/m); - assert.match(rootYamlContent, /^agent:$/m); - assert.match(rootYamlContent, /extend: default/); - assert.match(rootYamlContent, /system_prompt_path: \.\/gsd\.md/); - assert.match(rootYamlContent, /tools:/); - assert.match(rootYamlContent, /subagents:/); - assert.match(rootYamlContent, /kimi_cli\.tools\./); - assert.doesNotMatch(rootYamlContent, /mcp__/); - - const executorYamlContent = fs.readFileSync(executorYaml, 'utf8'); - assert.match(executorYamlContent, /system_prompt_path: \.\/gsd-executor\.md/); - assert.match(executorYamlContent, /kimi_cli\.tools\./); - assert.doesNotMatch(executorYamlContent, /mcp__/); - - assert.ok(fs.existsSync(path.join(tmpConfig, 'gsd-core', 'workflows', 'new-project.md')), 'must write workflow payloads used by Kimi skills'); - const manifestPath = path.join(tmpConfig, 'gsd-file-manifest.json'); - assert.ok(fs.existsSync(manifestPath), 'must write gsd-file-manifest.json for Kimi installs'); - const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); - assert.ok(manifest.files['skills/gsd-new-project/SKILL.md'], 'manifest tracks generated Kimi skill'); - assert.ok(manifest.files['agents/gsd.yaml'], 'manifest tracks Kimi root agent YAML'); - assert.ok(manifest.files['agents/gsd.md'], 'manifest tracks Kimi root agent prompt'); - assert.ok(manifest.files['agents/subagents/gsd-executor.yaml'], 'manifest tracks Kimi subagent YAML'); - assert.ok(manifest.files['agents/subagents/gsd-executor.md'], 'manifest tracks Kimi subagent prompt'); - assert.ok(manifest.files['gsd-core/workflows/new-project.md'], 'manifest tracks installed workflow payload'); - assert.ok(!fs.existsSync(path.join(tmpConfig, 'hooks')), 'must not write hooks under the Kimi root'); - assert.ok(!fs.existsSync(path.join(tmpConfig, 'settings.json')), 'must not write settings.json under the Kimi root'); - assert.ok(!fs.existsSync(path.join(tmpConfig, '.clinerules')), 'must not write rules under the Kimi root'); - } finally { - cleanup(tmpProject); - cleanup(tmpConfig); - cleanup(tmpHome); - } - }); - - test('--kimi --global backs up local edits to generated skills and agent artifacts on reinstall', () => { - const tmpProject = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-reinstall-project-')); - const tmpConfig = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-reinstall-config-')); - const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-reinstall-home-')); - const env = installerEnv({ HOME: tmpHome, USERPROFILE: tmpHome }); - - try { - const installArgs = [INSTALL_SCRIPT, '--kimi', '--global', '--config-dir', tmpConfig, '--no-sdk']; - const first = spawnSync(process.execPath, installArgs, { - cwd: tmpProject, - encoding: 'utf8', - env, - }); - assert.strictEqual( - first.status, - 0, - `first install failed\nstdout: ${first.stdout}\nstderr: ${first.stderr}`, - ); - - const skillFile = path.join(tmpConfig, 'skills', 'gsd-new-project', 'SKILL.md'); - const agentPrompt = path.join(tmpConfig, 'agents', 'subagents', 'gsd-executor.md'); - fs.appendFileSync(skillFile, '\nUSER LOCAL KIMI SKILL EDIT\n'); - fs.appendFileSync(agentPrompt, '\nUSER LOCAL KIMI AGENT EDIT\n'); - - const second = spawnSync(process.execPath, installArgs, { - cwd: tmpProject, - encoding: 'utf8', - env, - }); - assert.strictEqual( - second.status, - 0, - `second install failed\nstdout: ${second.stdout}\nstderr: ${second.stderr}`, - ); - const secondOutput = `${second.stdout}\n${second.stderr}`; - assert.match(secondOutput, /locally modified GSD file/i); - assert.match(secondOutput, /\/skill:gsd-update --reapply/); - assert.doesNotMatch(secondOutput, /Run\s+\/gsd-update --reapply/); - - const skillBackup = path.join(tmpConfig, 'gsd-local-patches', 'skills', 'gsd-new-project', 'SKILL.md'); - const agentBackup = path.join(tmpConfig, 'gsd-local-patches', 'agents', 'subagents', 'gsd-executor.md'); - assert.match(fs.readFileSync(skillBackup, 'utf8'), /USER LOCAL KIMI SKILL EDIT/); - assert.match(fs.readFileSync(agentBackup, 'utf8'), /USER LOCAL KIMI AGENT EDIT/); - - const meta = JSON.parse(fs.readFileSync(path.join(tmpConfig, 'gsd-local-patches', 'backup-meta.json'), 'utf8')); - assert.ok(meta.files.includes('skills/gsd-new-project/SKILL.md')); - assert.ok(meta.files.includes('agents/subagents/gsd-executor.md')); - } finally { - cleanup(tmpProject); - cleanup(tmpConfig); - cleanup(tmpHome); - } - }); - - test('--kimi --global --uninstall removes GSD artifacts and preserves non-GSD Kimi content', () => { - const tmpProject = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-uninstall-project-')); - const tmpConfig = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-uninstall-config-')); - const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-uninstall-home-')); - const env = installerEnv({ HOME: tmpHome, USERPROFILE: tmpHome }); - - try { - const installArgs = [INSTALL_SCRIPT, '--kimi', '--global', '--config-dir', tmpConfig, '--no-sdk']; - const installResult = spawnSync(process.execPath, installArgs, { - cwd: tmpProject, - encoding: 'utf8', - env, - }); - assert.strictEqual( - installResult.status, - 0, - `install failed\nstdout: ${installResult.stdout}\nstderr: ${installResult.stderr}`, - ); - - const foreignSkill = path.join(tmpConfig, 'skills', 'user-custom-skill', 'SKILL.md'); - const foreignRootAgent = path.join(tmpConfig, 'agents', 'user-agent.yaml'); - const foreignSubagent = path.join(tmpConfig, 'agents', 'subagents', 'user-agent.yaml'); - fs.mkdirSync(path.dirname(foreignSkill), { recursive: true }); - fs.mkdirSync(path.dirname(foreignSubagent), { recursive: true }); - fs.writeFileSync(foreignSkill, '# user skill\n', 'utf8'); - fs.writeFileSync(foreignRootAgent, 'version: 1\n', 'utf8'); - fs.writeFileSync(foreignSubagent, 'version: 1\n', 'utf8'); - - const uninstallResult = spawnSync( - process.execPath, - [INSTALL_SCRIPT, '--kimi', '--global', '--config-dir', tmpConfig, '--uninstall'], - { - cwd: tmpProject, - encoding: 'utf8', - env, - }, - ); - assert.strictEqual( - uninstallResult.status, - 0, - `uninstall failed\nstdout: ${uninstallResult.stdout}\nstderr: ${uninstallResult.stderr}`, - ); - assert.match(`${uninstallResult.stdout}\n${uninstallResult.stderr}`, /Kimi CLI/); - - assert.ok(!fs.existsSync(path.join(tmpConfig, 'skills', 'gsd-new-project')), 'must remove generated Kimi skills'); - assert.ok(!fs.existsSync(path.join(tmpConfig, 'agents', 'gsd.yaml')), 'must remove Kimi root agent YAML'); - assert.ok(!fs.existsSync(path.join(tmpConfig, 'agents', 'gsd.md')), 'must remove Kimi root agent prompt'); - assert.ok(!fs.existsSync(path.join(tmpConfig, 'agents', 'subagents', 'gsd-executor.yaml')), 'must remove generated Kimi subagent YAML'); - assert.ok(!fs.existsSync(path.join(tmpConfig, 'agents', 'subagents', 'gsd-executor.md')), 'must remove generated Kimi subagent prompt'); - assert.ok(!fs.existsSync(path.join(tmpConfig, 'gsd-core')), 'must remove installed workflow payload'); - assert.ok(!fs.existsSync(path.join(tmpConfig, 'gsd-file-manifest.json')), 'must remove the manifest'); - - assert.ok(fs.existsSync(foreignSkill), 'must preserve non-GSD Kimi skills'); - assert.ok(fs.existsSync(foreignRootAgent), 'must preserve non-GSD root agents'); - assert.ok(fs.existsSync(foreignSubagent), 'must preserve non-GSD subagents'); - } finally { - cleanup(tmpProject); - cleanup(tmpConfig); - cleanup(tmpHome); - } - }); -}); diff --git a/tests/capability-registry.test.cjs b/tests/capability-registry.test.cjs index 2f64d08f0..c84ed54b8 100644 --- a/tests/capability-registry.test.cjs +++ b/tests/capability-registry.test.cjs @@ -5272,3 +5272,1159 @@ describe('ADR-1244 D2: validator extraction generative parity', () => { assert.ok(validateCapability(noVersion, 'demo').some((e) => e.includes('version'))); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2530-valid-config-keys.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2530-valid-config-keys (consolidation epic #1969 B3 #1972)", () => { +'use strict'; + +/** + * Regression tests for config key bugs: + * #2530 — workflow._auto_chain_active is internal state, must not be in VALID_CONFIG_KEYS + * #2531 — hooks.workflow_guard is used by hook and documented but missing from VALID_CONFIG_KEYS + * #2532 — workflow.ui_review is used in autonomous.md but missing from config validation + * #2533 — workflow.max_discuss_passes is used in discuss-phase.md but missing from VALID_CONFIG_KEYS + * #2535 — sub_repos and plan_checker legacy keys need CONFIG_KEY_SUGGESTIONS migration hints + * #3162 — resolve_model_ids missing from VALID_CONFIG_KEYS; workflow._auto_chain_active must be + * accepted by isValidConfigKey (written by workflows) without being user-visible + * #1747 — buildNewProjectConfig emits four search-provider keys (tavily_search, ref_search, + * perplexity, jina) that research-provider.cts consumes but were missing from + * VALID_CONFIG_KEYS, causing /gsd-settings unknown-key warnings on fresh projects + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs'); + +const { + VALID_CONFIG_KEYS, + isCentralConfigKey, + isValidConfigKey, +} = require('../gsd-core/bin/lib/config-schema.cjs'); + +const capabilityRegistry = require('../gsd-core/bin/lib/capability-registry.cjs'); + +describe('VALID_CONFIG_KEYS correctness', () => { + test('#2530: workflow._auto_chain_active must not be in VALID_CONFIG_KEYS (internal state)', () => { + assert.ok( + !VALID_CONFIG_KEYS.has('workflow._auto_chain_active'), + 'workflow._auto_chain_active is internal runtime state and must not be user-settable' + ); + }); + + test('#2531: hooks.workflow_guard must be in VALID_CONFIG_KEYS (used by hook, documented)', () => { + assert.ok( + VALID_CONFIG_KEYS.has('hooks.workflow_guard'), + 'hooks.workflow_guard is read by gsd-workflow-guard.js hook and documented in CONFIGURATION.md' + ); + }); + + test('#2532: workflow.ui_review must remain valid but is no longer centrally owned', () => { + assert.strictEqual( + isValidConfigKey('workflow.ui_review'), + true, + 'workflow.ui_review is still user-facing config and must validate' + ); + assert.strictEqual( + isCentralConfigKey('workflow.ui_review'), + false, + 'workflow.ui_review is owned by the UI capability after ADR-857 Phase 6 cutover' + ); + }); + + test('#2533: workflow.max_discuss_passes must be in VALID_CONFIG_KEYS (used in discuss-phase.md)', () => { + assert.ok( + VALID_CONFIG_KEYS.has('workflow.max_discuss_passes'), + 'workflow.max_discuss_passes is read in discuss-phase.md via gsd-sdk query config-get' + ); + }); + + test('#3162: resolve_model_ids must be in VALID_CONFIG_KEYS (documented user-facing key)', () => { + assert.ok( + VALID_CONFIG_KEYS.has('resolve_model_ids'), + 'resolve_model_ids is documented in CONFIGURATION.md and read by core.cjs/session-runner.ts' + ); + }); + + test('#3162: workflow._auto_chain_active must be accepted by isValidConfigKey (written by workflows)', () => { + assert.strictEqual( + isValidConfigKey('workflow._auto_chain_active'), + true, + 'workflow._auto_chain_active is written by plan-phase, execute-phase, discuss-phase, transition workflows via config-set' + ); + }); +}); + +describe('#1747: new-project config emits only schema-recognized provider keys', () => { + // buildNewProjectConfig emits seven search-provider availability flags and + // research-provider.cts providerAvailability() consumes all seven, but only + // three were in VALID_CONFIG_KEYS → /gsd-settings warned on the four + // unregistered keys (tavily_search, ref_search, perplexity, jina) for every + // freshly generated .planning/config.json. + + // The four keys that were emitted + consumed but missing from the schema. + const MISSING_KEYS = ['tavily_search', 'ref_search', 'perplexity', 'jina']; + + // Every config-driven provider flag read by providerAvailability() in + // src/research-provider.cts. context7/websearch are excluded: hardcoded + // `true`, not config-gated, so no config key to register. + const PROVIDER_CONFIG_KEYS = [ + 'brave_search', + 'firecrawl', + 'exa_search', + 'tavily_search', + 'ref_search', + 'perplexity', + 'jina', + ]; + + test('the four previously-missing provider keys are in VALID_CONFIG_KEYS', () => { + const absent = MISSING_KEYS.filter((k) => !VALID_CONFIG_KEYS.has(k)); + assert.deepStrictEqual( + absent, + [], + `These provider keys are emitted by buildNewProjectConfig and consumed by research-provider.cts but missing from VALID_CONFIG_KEYS:\n ${absent.join('\n ')}\n\nAdd them to gsd-core/bin/shared/config-schema.manifest.json (validKeys).` + ); + }); + + test('every config-driven research-provider flag is registered in the schema (drift guard)', () => { + const drifted = PROVIDER_CONFIG_KEYS.filter((k) => !VALID_CONFIG_KEYS.has(k)); + assert.deepStrictEqual( + drifted, + [], + `These research-provider config flags are not in VALID_CONFIG_KEYS — a fresh /gsd-new-project config would trigger an unknown-key warning under /gsd-settings:\n ${drifted.join('\n ')}\n\nWhen you add a provider to providerAvailability() in src/research-provider.cts, also register its config key in gsd-core/bin/shared/config-schema.manifest.json.` + ); + }); +}); + +describe('ADR-857 Phase 6 capability config ownership', () => { + test('migrated capability config keys are valid through the registry, not central schema residue', () => { + const capabilityKeys = Object.keys(capabilityRegistry.configSchema || {}).sort(); + assert.ok(capabilityKeys.length > 0, 'expected generated registry config schema keys'); + + for (const key of capabilityKeys) { + assert.strictEqual(isValidConfigKey(key), true, `${key} must remain accepted by config validation`); + assert.strictEqual(isCentralConfigKey(key), false, `${key} must be capability-owned, not central`); + assert.strictEqual(VALID_CONFIG_KEYS.has(key), false, `${key} must not remain in central VALID_CONFIG_KEYS`); + } + }); +}); + +describe('CONFIG_KEY_SUGGESTIONS migration hints (#2535)', () => { + let tmpDir; + + test('config-set sub_repos emits "Did you mean planning.sub_repos?" suggestion', (t) => { + tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + + const result = runGsdTools(['config-set', 'sub_repos', '[]'], tmpDir); + assert.ok(!result.success, 'config-set sub_repos should fail'); + const combined = result.error + result.output; + assert.ok( + combined.includes('Did you mean') && combined.includes('planning.sub_repos'), + `Expected "Did you mean planning.sub_repos?" in error, got:\nstdout: ${result.output}\nstderr: ${result.error}` + ); + }); + + test('config-set plan_checker emits "Did you mean workflow.plan_check?" suggestion', (t) => { + tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + + const result = runGsdTools(['config-set', 'plan_checker', 'true'], tmpDir); + assert.ok(!result.success, 'config-set plan_checker should fail'); + const combined = result.error + result.output; + assert.ok( + combined.includes('Did you mean') && combined.includes('workflow.plan_check'), + `Expected "Did you mean workflow.plan_check?" in error, got:\nstdout: ${result.output}\nstderr: ${result.error}` + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2851-workflow-bare-gsd-tools.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2851-workflow-bare-gsd-tools (consolidation epic #1969 B3 #1972)", () => { +/** + * Bug #2851: plan-phase.md §13e calls bare `gsd-tools` — incomplete fix of #2245 + * + * Workflow bodies should not call `gsd-tools` as an unguarded bare command. + * Use the resolver snippets for SDK calls, or an explicit local CJS path when + * a command intentionally targets the checked-in legacy script: + * + * node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" [args] + * + * As of #621, the §13e gap-analysis call uses the `gsd_run` launcher (the + * canonical resolvable form) instead of the absolute-$HOME path above. + * Both forms are resolvable; `gsd_run` is now the preferred canonical form. + * + * Some workflow markdown files leaked the bare `gsd-tools ` form, + * which fails with `command not found` at runtime. + * + * This test parses every markdown file in gsd-core/workflows/ structurally: + * it tokenizes the content into fenced code blocks, then on each shell-block + * line checks whether `gsd-tools` appears as a bare command (not preceded by + * `node `, not part of the filename `gsd-tools.cjs`, not inside a comment). + * + * Per project rule: this test does NOT use grep/regex .includes() on raw file + * content as the assertion surface. Instead, it splits into code-fenced blocks + * and tokenizes each line — only command-position tokens count as violations. + */ +'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'); + +/** + * Extract shell-fenced code blocks from a markdown file. + * Returns an array of { startLine, lines } where lines are the contents + * between the ```bash / ```sh / ```shell fence markers. + */ +function extractShellBlocks(content) { + const allLines = content.split('\n'); + const blocks = []; + let inBlock = false; + let blockLang = null; + let blockStart = 0; + let blockLines = []; + + for (let i = 0; i < allLines.length; i++) { + const line = allLines[i]; + const fenceOpen = line.match(/^```(\w+)?/); + if (!inBlock && fenceOpen) { + inBlock = true; + blockLang = (fenceOpen[1] || '').toLowerCase(); + blockStart = i + 2; // 1-indexed line number of first content line + blockLines = []; + continue; + } + if (inBlock && /^```\s*$/.test(line)) { + if (['bash', 'sh', 'shell', 'zsh', ''].includes(blockLang)) { + blocks.push({ startLine: blockStart, lines: blockLines }); + } + inBlock = false; + blockLang = null; + blockLines = []; + continue; + } + if (inBlock) { + blockLines.push(line); + } + } + return blocks; +} + +/** + * Check a single shell-block line for a bare `gsd-tools` command-position token. + * Returns true if the line is a violation. + */ +function lineHasBareGsdTools(line) { + // Strip leading whitespace and any prompt prefix ($ , > , # ) + let l = line.replace(/^\s*[$>]\s*/, ''); + // Skip pure comment lines + if (/^\s*#/.test(l)) return false; + // Strip inline comment (# preceded by whitespace, not inside a string) + // Conservative: only strip if # appears after whitespace and outside quotes — + // we just look for the first ` #` outside of quoted context. For our needs, + // splitting on `^[^"']*?(\s#)` is good enough. + const hashIdx = l.search(/(?:^|[^"'\w])#/); + if (hashIdx > 0) l = l.slice(0, hashIdx); + + // Unwrap command-substitution forms so the substituted command is in + // command position. `$(cmd …)` and `` `cmd …` `` both run the inner string + // as a fresh command, so a bare `gsd-tools` inside them is just as broken + // as one at the start of the line. Iterate until stable for nested forms. + let prev; + do { + prev = l; + l = l.replace(/\$\(([^()]*)\)/g, ' $1 ').replace(/`([^`]*)`/g, ' $1 '); + } while (l !== prev); + + // Tokenize on whitespace, semicolons, pipes, and && / || + // Then walk tokens — a violation is a token that starts with `gsd-tools` + // followed by a word boundary (so `gsd-tools.cjs` does NOT match), and the + // preceding token is NOT `node`. + const segments = l.split(/(?:\s*(?:&&|\|\||;|\|)\s*)/); + for (const seg of segments) { + const tokens = seg.trim().split(/\s+/).filter(Boolean); + if (tokens.length === 0) continue; + // Skip env var assignments at the start (FOO=bar gsd-tools …, tmp=1 gsd-tools …). + // POSIX shell variable names are [A-Za-z_][A-Za-z0-9_]*; lowercase is valid. + let cmdIdx = 0; + while (cmdIdx < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[cmdIdx])) { + cmdIdx++; + } + if (cmdIdx >= tokens.length) continue; + const cmd = tokens[cmdIdx]; + // Match `gsd-tools` exactly (no extension), as command position. + if (cmd === 'gsd-tools') return true; + } + return false; +} + +const AGENTS_DIR = path.join(__dirname, '..', 'agents'); + +describe('bug #1041: agent files must not call bare gsd-tools (all-runtime resolver)', () => { + test('no agents/gsd-*.md file contains a bare gsd-tools command', () => { + const files = fs.readdirSync(AGENTS_DIR).filter((f) => f.startsWith('gsd-') && f.endsWith('.md')); + assert.ok(files.length > 0, 'expected agent files to exist'); + + const violations = []; + for (const f of files) { + const full = path.join(AGENTS_DIR, f); + const content = fs.readFileSync(full, 'utf-8'); + const blocks = extractShellBlocks(content); + for (const blk of blocks) { + for (let i = 0; i < blk.lines.length; i++) { + if (lineHasBareGsdTools(blk.lines[i])) { + violations.push(`${f}:${blk.startLine + i}: ${blk.lines[i].trim()}`); + } + } + } + } + + assert.deepStrictEqual( + violations, + [], + 'Bare `gsd-tools` invocations found in agent shell blocks. ' + + 'Inject the runtime-launcher preamble (_runtime-launcher.snippet.sh) and use `gsd_run` instead.\n' + + violations.join('\n'), + ); + }); +}); + +describe('bug-2851: workflow files must not call bare `gsd-tools` (#2245 sweep regression)', () => { + test('no gsd-core/workflows/*.md file contains a bare gsd-tools command', () => { + const files = fs.readdirSync(WORKFLOWS_DIR).filter((f) => f.endsWith('.md')); + assert.ok(files.length > 0, 'expected workflow files to exist'); + + const violations = []; + for (const f of files) { + const full = path.join(WORKFLOWS_DIR, f); + const content = fs.readFileSync(full, 'utf-8'); + const blocks = extractShellBlocks(content); + for (const blk of blocks) { + for (let i = 0; i < blk.lines.length; i++) { + if (lineHasBareGsdTools(blk.lines[i])) { + violations.push(`${f}:${blk.startLine + i}: ${blk.lines[i].trim()}`); + } + } + } + } + + assert.deepStrictEqual( + violations, + [], + 'Bare `gsd-tools` invocations found in workflow shell blocks. ' + + 'Use a resolver snippet or `node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" ` instead.\n' + + violations.join('\n'), + ); + }); + + test('plan-phase.md §13e gap-analysis dispatches via gsd_run loop render-hooks plan:post (ADR-857 capability gate, #621)', () => { + const planPhase = fs.readFileSync(path.join(WORKFLOWS_DIR, 'plan-phase.md'), 'utf-8'); + const blocks = extractShellBlocks(planPhase); + let foundPlanPostDispatch = false; + for (const blk of blocks) { + for (const line of blk.lines) { + if (/gsd_run\s+loop\s+render-hooks\s+plan:post\s+--raw/.test(line) && !/^\s*#/.test(line)) { + foundPlanPostDispatch = true; + } + } + } + assert.ok( + foundPlanPostDispatch, + 'expected plan-phase.md §13e to dispatch gsd_run loop render-hooks plan:post --raw (gap-analysis moved to capability gate plan:post in ADR-857 migration)', + ); + const registry = require('../gsd-core/bin/lib/capability-registry.cjs'); + const planPostPoint = (registry.byLoopPoint || {})['plan:post'] || {}; + const gates = planPostPoint.gates || []; + const gapAnalysisGate = gates.find((g) => g.capId === 'gap-analysis'); + assert.ok( + gapAnalysisGate, + 'gap-analysis capability must be registered as a plan:post gate in capability-registry.cjs', + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/fix-1628-config-set-validation.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:fix-1628-config-set-validation (consolidation epic #1969 B3 #1972)", () => { +'use strict'; + +/** + * Regression test suite for bug #1628: config-set validation gaps. + * + * This file consolidates all #1628 config-set validation regression tests: + * 1. Security-key enum guards (workflow.security_block_on, workflow.security_asvs_level) + * 2. JSON-array coercion bypass: every affected string-enum key + * 3. Generic capability-registry validation (enum/boolean/number/string keys) + * + * Covers: + * - workflow.security_block_on must be one of: critical | high | medium | low | none + * - workflow.security_asvs_level must be an integer in {1, 2, 3} + * - JSON-array ([""]) and JSON-object ({"x":1}) values must be REJECTED for + * all string-enum keys (typeof check before enum guard) + * - capability-registry-owned keys: ENUM, BOOLEAN, NUMBER, STRING + * + * Boundary coverage per RULESET.TESTS.boundary-coverage: + * security_asvs_level: 0 (limit-1), 1 (limit), 2, 3 (limit), 4 (limit+1) + * security_block_on: each valid enum member + bogus values + * + * Registry canary: verifies capability registry's .values for workflow.security_block_on + * matches the canonical enum (guards against silent gutting per DEFECT.GENERATIVE-FIX). + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs'); + +// ─── Registry canary ────────────────────────────────────────────────────────── +// Verify the capability registry's declared .values for workflow.security_block_on +// matches the canonical enum. config.cts sources its allowed set DIRECTLY from the +// registry, so this canary guards against the registry being silently gutted — which +// would cause every config-set call to fail (per DEFECT.GENERATIVE-FIX). +describe('fix-1628: registry canary — capability registry declares the canonical security_block_on enum', () => { + test('registry workflow.security_block_on.values declares the expected canonical enum', () => { + // Load the capability registry as a module (behavioral call, not source grep). + // The registry IS the source of truth: config.cts reads from it at runtime. + // This assertion guards against the registry entry being gutted or values removed. + const { configSchema } = require('../gsd-core/bin/lib/capability-registry.cjs'); + const entry = configSchema['workflow.security_block_on']; + assert.ok(entry, 'capability registry must have an entry for workflow.security_block_on'); + assert.ok(Array.isArray(entry.values), 'registry entry must have a .values array'); + + const EXPECTED = ['critical', 'high', 'medium', 'low', 'none']; + assert.deepEqual( + [...entry.values].sort(), + [...EXPECTED].sort(), + `Registry workflow.security_block_on.values must be ${JSON.stringify(EXPECTED)} — update ` + + `the capability registry if the canonical enum changes` + ); + }); +}); + +// ─── workflow.security_block_on ─────────────────────────────────────────────── + +describe('fix-1628: workflow.security_block_on enum validation', () => { + const VALID_VALUES = ['critical', 'high', 'medium', 'low', 'none']; + const INVALID_VALUES = ['bogus', 'High', 'CRITICAL', '', 'all', 'urgent']; + + for (const v of VALID_VALUES) { + test(`config-set workflow.security_block_on=${v} is ACCEPTED`, (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools( + ['config-set', 'workflow.security_block_on', v], + tmpDir + ); + assert.ok( + result.success, + [ + `config-set workflow.security_block_on=${v} must succeed,`, + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); + } + + for (const v of INVALID_VALUES) { + test(`config-set workflow.security_block_on=${JSON.stringify(v)} is REJECTED`, (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools( + ['config-set', 'workflow.security_block_on', v], + tmpDir + ); + assert.ok( + !result.success, + `config-set workflow.security_block_on=${JSON.stringify(v)} must fail, but it succeeded` + ); + const combined = (result.output || '') + (result.error || ''); + // Error message must mention the valid values + assert.ok( + combined.includes('critical') && combined.includes('none'), + `Error message must mention valid values (got: ${combined})` + ); + }); + } +}); + +// ─── workflow.security_block_on — JSON-parse coercion bypass ───────────────── +// Regression for the String(parsedValue) coercion bug: an array like ["high"] +// coerces to "high" via String(), bypassing the enum check and writing an array +// to a string-enum key. The fix requires typeof parsedValue === 'string'. + +describe('fix-1628: workflow.security_block_on rejects JSON-parsed non-string inputs', () => { + const JSON_BYPASS_CASES = [ + { val: '["high"]', label: 'JSON array with valid member' }, + { val: '["bogus"]', label: 'JSON array with invalid member' }, + { val: '{"high":1}', label: 'JSON object' }, + ]; + + for (const { val, label } of JSON_BYPASS_CASES) { + test(`config-set workflow.security_block_on=${label} is REJECTED`, (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools( + ['config-set', 'workflow.security_block_on', val], + tmpDir + ); + assert.ok( + !result.success, + `config-set workflow.security_block_on=${label} must fail, but it succeeded` + ); + }); + } +}); + +// ─── workflow.security_asvs_level ───────────────────────────────────────────── + +describe('fix-1628: workflow.security_asvs_level range validation', () => { + // Boundary: 0 (below limit), 1 (min valid), 2 (mid), 3 (max valid), 4 (above limit) + const ACCEPTED_INTEGERS = [1, 2, 3]; + const REJECTED_VALUES = [ + { val: '0', label: '0 (below lower bound)' }, + { val: '4', label: '4 (above upper bound)' }, + { val: '2.5', label: '2.5 (non-integer float)' }, + { val: 'abc', label: '"abc" (non-numeric string)' }, + { val: '-1', label: '-1 (negative)' }, + ]; + + for (const n of ACCEPTED_INTEGERS) { + test(`config-set workflow.security_asvs_level=${n} is ACCEPTED`, (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools( + ['config-set', 'workflow.security_asvs_level', String(n)], + tmpDir + ); + assert.ok( + result.success, + [ + `config-set workflow.security_asvs_level=${n} must succeed,`, + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); + } + + for (const { val, label } of REJECTED_VALUES) { + test(`config-set workflow.security_asvs_level=${label} is REJECTED`, (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools( + ['config-set', 'workflow.security_asvs_level', val], + tmpDir + ); + assert.ok( + !result.success, + `config-set workflow.security_asvs_level=${label} must fail, but it succeeded` + ); + const combined = (result.output || '') + (result.error || ''); + assert.ok( + combined.includes('security_asvs_level'), + `Error message must reference the key name (got: ${combined})` + ); + }); + } +}); + +// ─── JSON-array coercion bypass — parameterised matrix ─────────────────────── +// The root cause: cmdConfigSet JSON-parses any value starting with '[' or '{' +// BEFORE per-key enum guards run. Guards using `.includes(String(parsedValue))` +// are then fooled because `String(["mid-flight"]) === "mid-flight"`, so the +// array bypasses the guard and gets stored in a scalar key. +// +// The fix: `assertEnumValue()` checks `typeof parsedValue === 'string'` FIRST, +// so a parsed array is rejected regardless of its string coercion. +// +// Coverage: every affected string-enum key. +// - `[""]` (JSON array with valid member) → REJECTED +// - `{"x":1}` (JSON object) → REJECTED +// - `` (plain string, valid) → ACCEPTED + +// Each row: { key, member } where `member` is a valid enum value for `key`. +// Verified against VALID_* arrays in src/config.cts. +const ENUM_KEYS = [ + { key: 'context', member: 'research' }, + { key: 'workflow.drift_action', member: 'warn' }, + { key: 'workflow.human_verify_mode', member: 'mid-flight' }, + { key: 'workflow.context_guard_mode', member: 'off' }, + { key: 'statusline.context_position', member: 'front' }, + { key: 'code_quality.fallow.scope', member: 'phase' }, + { key: 'code_quality.fallow.profile', member: 'standard' }, + { key: 'plan_review.source_grounding_authority', member: 'grep' }, + { key: 'workflow.security_block_on', member: 'high' }, +]; + +for (const { key, member } of ENUM_KEYS) { + describe(`fix-1628 coercion bypass: ${key}`, () => { + // ── JSON array with valid member must be REJECTED ──────────────────────── + test(`["${member}"] (JSON array with valid member) is REJECTED`, (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const val = `["${member}"]`; + const result = runGsdTools(['config-set', key, val], tmpDir); + assert.ok( + !result.success, + [ + `config-set ${key}=${val} must be REJECTED (JSON-array coercion bypass)`, + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); + + // ── JSON object must be REJECTED ───────────────────────────────────────── + test(`{"x":1} (JSON object) is REJECTED`, (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const val = '{"x":1}'; + const result = runGsdTools(['config-set', key, val], tmpDir); + assert.ok( + !result.success, + [ + `config-set ${key}=${val} must be REJECTED (JSON-object bypass)`, + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); + + // ── Plain valid string must be ACCEPTED ────────────────────────────────── + test(`"${member}" (plain valid string) is ACCEPTED`, (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['config-set', key, member], tmpDir); + assert.ok( + result.success, + [ + `config-set ${key}=${member} must be ACCEPTED (plain string, valid enum member)`, + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); + }); +} + +// ─── ENUM: workflow.code_review_depth ──────────────────────────────────────── + +describe('fix-1628 capability validation: workflow.code_review_depth (enum)', () => { + const VALID_VALUES = ['quick', 'standard', 'deep']; + + for (const v of VALID_VALUES) { + test(`config-set workflow.code_review_depth=${v} is ACCEPTED`, (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['config-set', 'workflow.code_review_depth', v], tmpDir); + assert.ok( + result.success, + [ + `config-set workflow.code_review_depth=${v} must succeed`, + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); + } + + test('config-set workflow.code_review_depth=["standard"] (JSON array bypass) is REJECTED', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['config-set', 'workflow.code_review_depth', '["standard"]'], tmpDir); + assert.ok( + !result.success, + [ + 'config-set workflow.code_review_depth=["standard"] must be REJECTED (JSON-array coercion bypass)', + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); + + test('config-set workflow.code_review_depth=garbage is REJECTED', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['config-set', 'workflow.code_review_depth', 'garbage'], tmpDir); + assert.ok( + !result.success, + [ + 'config-set workflow.code_review_depth=garbage must be REJECTED (out-of-enum)', + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); +}); + +// ─── ENUM: mempalace.memory_mode ───────────────────────────────────────────── + +describe('fix-1628 capability validation: mempalace.memory_mode (enum)', () => { + const VALID_VALUES = ['augment', 'kg_backend', 'replace']; + + for (const v of VALID_VALUES) { + test(`config-set mempalace.memory_mode=${v} is ACCEPTED`, (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['config-set', 'mempalace.memory_mode', v], tmpDir); + assert.ok( + result.success, + [ + `config-set mempalace.memory_mode=${v} must succeed`, + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); + } + + test('config-set mempalace.memory_mode=["augment"] (JSON array bypass) is REJECTED', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['config-set', 'mempalace.memory_mode', '["augment"]'], tmpDir); + assert.ok( + !result.success, + [ + 'config-set mempalace.memory_mode=["augment"] must be REJECTED (JSON-array coercion bypass)', + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); + + test('config-set mempalace.memory_mode=garbage is REJECTED', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['config-set', 'mempalace.memory_mode', 'garbage'], tmpDir); + assert.ok( + !result.success, + [ + 'config-set mempalace.memory_mode=garbage must be REJECTED (out-of-enum)', + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); +}); + +// ─── BOOLEAN: workflow.tdd_mode ────────────────────────────────────────────── + +describe('fix-1628 capability validation: workflow.tdd_mode (boolean)', () => { + test('config-set workflow.tdd_mode=true is ACCEPTED', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['config-set', 'workflow.tdd_mode', 'true'], tmpDir); + assert.ok( + result.success, + [ + 'config-set workflow.tdd_mode=true must succeed', + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); + + test('config-set workflow.tdd_mode=false is ACCEPTED', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['config-set', 'workflow.tdd_mode', 'false'], tmpDir); + assert.ok( + result.success, + [ + 'config-set workflow.tdd_mode=false must succeed', + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); + + test('config-set workflow.tdd_mode=["true"] (JSON array bypass) is REJECTED', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['config-set', 'workflow.tdd_mode', '["true"]'], tmpDir); + assert.ok( + !result.success, + [ + 'config-set workflow.tdd_mode=["true"] must be REJECTED (JSON-array coercion bypass)', + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); + + test('config-set workflow.tdd_mode={"x":1} (JSON object) is REJECTED', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['config-set', 'workflow.tdd_mode', '{"x":1}'], tmpDir); + assert.ok( + !result.success, + [ + 'config-set workflow.tdd_mode={"x":1} must be REJECTED (JSON-object bypass)', + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); + + test('config-set workflow.tdd_mode=maybe (non-boolean string) is REJECTED', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['config-set', 'workflow.tdd_mode', 'maybe'], tmpDir); + assert.ok( + !result.success, + [ + 'config-set workflow.tdd_mode=maybe must be REJECTED (non-boolean string)', + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); + + test('config-set workflow.tdd_mode=1 (numeric 1 coerces to number, not boolean) is REJECTED', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['config-set', 'workflow.tdd_mode', '1'], tmpDir); + assert.ok( + !result.success, + [ + 'config-set workflow.tdd_mode=1 must be REJECTED (number, not boolean)', + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); +}); + +// ─── BOOLEAN: graphify.enabled ──────────────────────────────────────────────── + +describe('fix-1628 capability validation: graphify.enabled (boolean)', () => { + test('config-set graphify.enabled=true is ACCEPTED', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['config-set', 'graphify.enabled', 'true'], tmpDir); + assert.ok( + result.success, + [ + 'config-set graphify.enabled=true must succeed', + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); + + test('config-set graphify.enabled=false is ACCEPTED', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['config-set', 'graphify.enabled', 'false'], tmpDir); + assert.ok( + result.success, + [ + 'config-set graphify.enabled=false must succeed', + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); + + test('config-set graphify.enabled=["true"] (JSON array bypass) is REJECTED', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['config-set', 'graphify.enabled', '["true"]'], tmpDir); + assert.ok( + !result.success, + [ + 'config-set graphify.enabled=["true"] must be REJECTED (JSON-array coercion bypass)', + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); + + test('config-set graphify.enabled={"x":1} (JSON object) is REJECTED', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['config-set', 'graphify.enabled', '{"x":1}'], tmpDir); + assert.ok( + !result.success, + [ + 'config-set graphify.enabled={"x":1} must be REJECTED (JSON-object bypass)', + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); + + test('config-set graphify.enabled=maybe (non-boolean string) is REJECTED', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['config-set', 'graphify.enabled', 'maybe'], tmpDir); + assert.ok( + !result.success, + [ + 'config-set graphify.enabled=maybe must be REJECTED (non-boolean string)', + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); + + test('config-set graphify.enabled=1 (numeric 1, not boolean) is REJECTED', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['config-set', 'graphify.enabled', '1'], tmpDir); + assert.ok( + !result.success, + [ + 'config-set graphify.enabled=1 must be REJECTED (number, not boolean)', + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); +}); + +// ─── NUMBER: workflow.drift_threshold ──────────────────────────────────────── + +describe('fix-1628 capability validation: workflow.drift_threshold (number)', () => { + test('config-set workflow.drift_threshold=5 is ACCEPTED', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['config-set', 'workflow.drift_threshold', '5'], tmpDir); + assert.ok( + result.success, + [ + 'config-set workflow.drift_threshold=5 must succeed', + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); + + test('config-set workflow.drift_threshold=["3"] (JSON array bypass) is REJECTED', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['config-set', 'workflow.drift_threshold', '["3"]'], tmpDir); + assert.ok( + !result.success, + [ + 'config-set workflow.drift_threshold=["3"] must be REJECTED (JSON-array coercion bypass)', + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); +}); + +// ─── STRING: mempalace.wing ─────────────────────────────────────────────────── + +describe('fix-1628 capability validation: mempalace.wing (string)', () => { + test('config-set mempalace.wing=myWing is ACCEPTED', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['config-set', 'mempalace.wing', 'myWing'], tmpDir); + assert.ok( + result.success, + [ + 'config-set mempalace.wing=myWing must succeed', + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); + + test('config-set mempalace.wing=["x"] (JSON array bypass) is REJECTED', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['config-set', 'mempalace.wing', '["x"]'], tmpDir); + assert.ok( + !result.success, + [ + 'config-set mempalace.wing=["x"] must be REJECTED (JSON-array coercion bypass)', + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); + + test('config-set mempalace.wing={"a":1} (JSON object) is REJECTED', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['config-set', 'mempalace.wing', '{"a":1}'], tmpDir); + assert.ok( + !result.success, + [ + 'config-set mempalace.wing={"a":1} must be REJECTED (JSON-object bypass)', + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-1055-config-intent-descriptor-drive.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-1055-config-intent-descriptor-drive (consolidation epic #1969 B3 #1972)", () => { +'use strict'; + +/** + * ADR-857 phase 5g drive 2: resolveRuntimeConfigIntent is now driven by the + * runtime capability descriptor (capability-registry.cjs) rather than a + * hand-kept REGISTRY const. + * + * This golden-master test pins the observable contract: the return shape and + * values must be identical to the pre-change behavior for all 16 runtimes. + * Purely behavioral — no source-grep. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const ROOT = path.join(__dirname, '..'); +const { + resolveRuntimeConfigIntent, + ALLOWED_CONFIG_RUNTIMES, +} = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-config-adapter-registry.cjs')); + +// --------------------------------------------------------------------------- +// Frozen expected table (pre-change behavior — the contract being pinned) +// --------------------------------------------------------------------------- + +const EXPECTED = [ + { runtime: 'claude', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null }, + { runtime: 'gemini', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null }, + { runtime: 'antigravity', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null }, + { runtime: 'augment', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null }, + { runtime: 'qwen', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null }, + { runtime: 'hermes', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null }, + { runtime: 'codebuddy', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null }, + { runtime: 'opencode', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: 'opencode' }, + { runtime: 'kilo', installSurface: 'settings-json', writesSharedSettings: false, finishPermissionWriter: 'kilo' }, + { runtime: 'codex', installSurface: 'codex-toml', writesSharedSettings: false, finishPermissionWriter: null }, + { runtime: 'copilot', installSurface: 'copilot-instructions', writesSharedSettings: false, finishPermissionWriter: null }, + { runtime: 'cline', installSurface: 'cline-rules', writesSharedSettings: false, finishPermissionWriter: null }, + { runtime: 'cursor', installSurface: 'cursor-hooks-json', writesSharedSettings: false, finishPermissionWriter: null }, + { runtime: 'windsurf', installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null }, + { runtime: 'trae', installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null }, + { runtime: 'kimi', installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null }, +]; + +// --------------------------------------------------------------------------- +// Test 1: Golden master — all 16 runtimes resolve to expected values +// --------------------------------------------------------------------------- + +describe('enh-1055 descriptor-drive: resolveRuntimeConfigIntent golden master', () => { + for (const row of EXPECTED) { + test(`${row.runtime} resolves to expected intent`, () => { + const intent = resolveRuntimeConfigIntent(row.runtime); + assert.deepStrictEqual(intent, { + runtime: row.runtime, + installSurface: row.installSurface, + writesSharedSettings: row.writesSharedSettings, + finishPermissionWriter: row.finishPermissionWriter, + }); + }); + } +}); + +// --------------------------------------------------------------------------- +// Test 3: Unknown runtime throws TypeError +// --------------------------------------------------------------------------- + +describe('enh-1055 descriptor-drive: unknown runtime throws TypeError', () => { + test('throws TypeError for "bogus-runtime"', () => { + assert.throws(() => resolveRuntimeConfigIntent('bogus-runtime'), TypeError); + }); + + test('throws TypeError for empty string', () => { + assert.throws(() => resolveRuntimeConfigIntent(''), TypeError); + }); + + test('throws TypeError for undefined', () => { + assert.throws(() => resolveRuntimeConfigIntent(undefined), TypeError); + }); + + test('throws TypeError for "__proto__"', () => { + assert.throws(() => resolveRuntimeConfigIntent('__proto__'), TypeError); + }); +}); + +// --------------------------------------------------------------------------- +// Test 4: ALLOWED_CONFIG_RUNTIMES contains all 16 expected runtimes +// --------------------------------------------------------------------------- + +describe('enh-1055 descriptor-drive: ALLOWED_CONFIG_RUNTIMES completeness', () => { + const EXPECTED_16 = new Set([ + 'claude', 'gemini', 'antigravity', 'augment', 'qwen', 'hermes', 'codebuddy', + 'opencode', 'kilo', 'codex', 'copilot', 'cline', 'cursor', 'windsurf', 'trae', 'kimi', + ]); + + test('contains exactly the 16 expected runtimes', () => { + assert.deepStrictEqual(new Set(ALLOWED_CONFIG_RUNTIMES), EXPECTED_16); + }); + + test('has exactly 16 entries', () => { + assert.strictEqual([...ALLOWED_CONFIG_RUNTIMES].length, 16); + }); +}); + +// --------------------------------------------------------------------------- +// Test 5: Descriptor drive — the function reads from the descriptor, not +// a hardcoded local constant. This is proven indirectly: the golden master +// passes, meaning capability-registry.cjs (the live descriptor) matches the +// expected table. If the adapter had its own REGISTRY, a descriptor change +// would diverge silently; with drive, it cannot. +// --------------------------------------------------------------------------- + +describe('enh-1055 descriptor-drive: finishPermissionWriter passthrough', () => { + test('opencode → "opencode" (descriptor permissionWriter)', () => { + assert.strictEqual(resolveRuntimeConfigIntent('opencode').finishPermissionWriter, 'opencode'); + }); + + test('kilo → "kilo" (descriptor permissionWriter)', () => { + assert.strictEqual(resolveRuntimeConfigIntent('kilo').finishPermissionWriter, 'kilo'); + }); + + test('all other runtimes have finishPermissionWriter === null', () => { + const nullExpected = EXPECTED + .filter(r => r.finishPermissionWriter === null) + .map(r => r.runtime); + for (const runtime of nullExpected) { + assert.strictEqual( + resolveRuntimeConfigIntent(runtime).finishPermissionWriter, + null, + `${runtime} should have finishPermissionWriter null`, + ); + } + }); +}); + }); +} diff --git a/tests/check-ui-safety-gate.test.cjs b/tests/check-ui-safety-gate.test.cjs index 91083776f..4c17fa657 100644 --- a/tests/check-ui-safety-gate.test.cjs +++ b/tests/check-ui-safety-gate.test.cjs @@ -257,3 +257,411 @@ describe('computeUiSafetyGate — ui.safety-gate check logic (#1168)', () => { }); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3706-ui-safety-gate-false-positives.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3706-ui-safety-gate-false-positives (consolidation epic #1969 B3 #1972)", () => { +'use strict'; + +/** + * Tests for bug #3706 (word-boundary anchoring) and #3718 (cross-shell portability). + * + * Root cause (#3706): grep -iE "UI|..." had no word-boundary anchoring, causing + * false-positives on "Requirements" (contains "ui"), "overview" ("view"), etc. + * + * Root cause (#3718): shell-based invocation (with locale env-var prefix) silently + * degrades on Windows PowerShell — the prefix is not recognised by pwsh. + * + * Fix (#3718, Approach A): gate logic moved to `bin/lib/ui-safety-gate.cjs` (Node.js). + * Reads phase text from STDIN (not argv) to avoid OS ARG_MAX limits. + * Invoked as: printf '%s' "$PHASE_SECTION" | node "${GSD_REPO_ROOT}/bin/lib/ui-safety-gate.cjs" + * Path anchored to repo root via `git rev-parse --show-toplevel`. + * + * Test strategy: + * 1. Import the helper module directly and assert correct results on the full fixture + * matrix (exercises the production code path). + * 2. Spawn the helper as a child process with shell:false and input on stdin to prove + * cross-shell portability — spawnSync with shell:false bypasses any host shell. + * 3. Assert the workflow .md files now invoke `node ... ui-safety-gate.cjs` via stdin + * rather than the old shell-based invocation (structural guard against regression). + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('node:child_process'); +const os = require('node:os'); +const { cleanup } = require('./helpers.cjs'); + +const HELPER_PATH = path.join(__dirname, '..', 'bin', 'lib', 'ui-safety-gate.cjs'); +const PLAN_PHASE_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'plan-phase.md'); +const AUTONOMOUS_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'autonomous.md'); + +const { checkUiPresence } = require(HELPER_PATH); + +// ── Helper ──────────────────────────────────────────────────────────────────── + +/** + * Mirrors the old `hasUiGate` helper for backwards-compatible assertions. + * Returns 0 (UI found) or 1 (not found), matching grep exit code semantics. + */ +function hasUiGate(text) { + return checkUiPresence(text).hasUI ? 0 : 1; +} + +/** + * Spawn the helper with shell:false, passing input via stdin. + * Returns the spawnSync result object. + */ +function spawnGate(input) { + return spawnSync(process.execPath, [HELPER_PATH], { + shell: false, + encoding: 'utf-8', + input: input, + }); +} + +// ── Structural guard — workflow files now invoke Node via stdin ─────────────── + +describe('Workflow .md structural guard (#3718)', () => { + // allow-test-rule: source-text-is-the-product (see #3706) + // The UI safety gate invocation is embedded in workflow prose-as-code. + // These structural tests guard against regression where someone re-introduces + // the shell-based locale-prefix invocation that silently breaks on Windows PowerShell. + + // plan-phase.md (post-#1026): §5.6 now delegates UI gate evaluation to + // `gsd_run check ui-plan-gate` (a CLI command that internally calls checkUiPresence + // from ui-safety-gate.cjs). The direct shell invocation of ui-safety-gate.cjs was + // intentionally removed from the workflow — cross-shell portability is now provided + // by the CLI command layer, not inline shell code. + test('plan-phase.md must invoke check ui-plan-gate (capability-driven UI gate — #1026)', () => { + const content = fs.readFileSync(PLAN_PHASE_PATH, 'utf-8'); + assert.ok( + content.includes('check ui-plan-gate'), + 'plan-phase.md: §5.6 must delegate to `check ui-plan-gate` (capability-driven, #1026)' + ); + assert.ok( + !content.includes('LC_ALL=C grep'), + 'plan-phase.md: must NOT contain LC_ALL=C grep — that silently fails on Windows PowerShell (#3718)' + ); + // Must NOT reintroduce the old shell-based path-search loop for ui-safety-gate.cjs + assert.ok( + !content.includes('UI_GATE_JS=$(for _c in'), + 'plan-phase.md: must NOT contain the old shell-based ui-safety-gate.cjs path-search (old §5.6 pattern)' + ); + }); + + // autonomous.md §3a.5 (post-#1031 cutover): §3a.5 now delegates to + // `loop render-hooks plan:pre` + `check ui-plan-gate` (capability-driven pattern, + // matching plan-phase.md §5.6). The direct shell invocation of ui-safety-gate.cjs + // was intentionally removed — cross-shell portability is provided by the CLI layer. + test('autonomous.md must invoke check ui-plan-gate (capability-driven UI gate — #1031)', () => { + const label = 'autonomous.md'; + const content = fs.readFileSync(AUTONOMOUS_PATH, 'utf-8'); + + // §3a.5 must delegate to the capability-driven gate, not inline shell code + assert.ok( + content.includes('check ui-plan-gate'), + `${label}: §3a.5 must delegate to \`check ui-plan-gate\` (capability-driven, #1031)` + ); + // §3a.5 must dispatch render-hooks plan:pre + assert.ok( + content.includes('loop render-hooks plan:pre'), + `${label}: §3a.5 must dispatch \`loop render-hooks plan:pre\` (capability hook resolution)` + ); + // Must NOT reintroduce the old shell-based path-search loop for ui-safety-gate.cjs + assert.ok( + !content.includes('ui-safety-gate.cjs'), + `${label}: must NOT inline ui-safety-gate.cjs invocation — replaced by check ui-plan-gate (#1031)` + ); + assert.ok( + !content.includes('UI_GATE_JS=$(for _c in'), + `${label}: must NOT contain the old shell-based ui-safety-gate.cjs path-search (old §3a.5 pattern)` + ); + assert.ok( + !content.includes('LC_ALL=C grep'), + `${label}: must NOT contain LC_ALL=C grep — that silently fails on Windows PowerShell (#3718)` + ); + }); +}); + +// ── Cross-shell spawn test (#3718) ──────────────────────────────────────────── + +describe('Cross-shell portability — spawnSync with shell:false, stdin (#3718)', () => { + test('spawn with shell:false + stdin: UI input exits 0', () => { + const result = spawnGate('UI Refactor: migrate all screens'); + assert.strictEqual(result.status, 0, `Expected exit 0 (UI found), got ${result.status}. stderr: ${result.stderr}`); + }); + + test('spawn with shell:false + stdin: non-UI input exits 1', () => { + const result = spawnGate('Requirements: backend REST API only'); + assert.strictEqual(result.status, 1, `Expected exit 1 (no UI), got ${result.status}. stderr: ${result.stderr}`); + }); + + test('spawn with shell:false + stdin: empty input exits 1', () => { + const result = spawnGate(''); + assert.strictEqual(result.status, 1, `Expected exit 1 (no UI for empty input), got ${result.status}. stderr: ${result.stderr}`); + }); + + test('spawn with shell:false + stdin: CRLF line endings handled correctly', () => { + const result = spawnGate('Deploy to the cloud platform\r\nand configure CI/CD.'); + assert.strictEqual(result.status, 1, `CRLF "platform" must not trigger gate. stderr: ${result.stderr}`); + }); + + test('spawn with shell:false + stdin: multi-line input with UI token exits 0', () => { + const multiLine = 'Backend setup\nBuild the analytics dashboard\nand navigation component.'; + const result = spawnGate(multiLine); + assert.strictEqual(result.status, 0, `Multi-line input with "dashboard" must exit 0. stderr: ${result.stderr}`); + }); + + test('spawn with shell:false + stdin: very large input (>100KB) is handled without error', () => { + // Verifies stdin transport does not hit ARG_MAX (argv transport fails above ~1MB on macOS). + // Fixture uses pure backend prose — no standalone UI tokens. + const largeInput = 'Backend service deployment and database migration step.\n'.repeat(3000); + const result = spawnGate(largeInput); + assert.strictEqual(result.status, 1, `Large non-UI input must exit 1, not crash. status: ${result.status}, stderr: ${result.stderr}`); + }); + + test('spawn with shell:false + stdin: large input with UI token exits 0', () => { + const largeUiInput = 'Backend infrastructure setup.\n'.repeat(2999) + 'Build the analytics dashboard.\n'; + const result = spawnGate(largeUiInput); + assert.strictEqual(result.status, 0, `Large input ending with UI token must exit 0. stderr: ${result.stderr}`); + }); +}); + +// ── Behavioral test matrix (via module import) ──────────────────────────────── + +/** + * Full fixture matrix — exercises the production checkUiPresence() function directly. + */ +function runBehavioralTests(label) { + describe(`${label} — UI gate behavioral matrix`, () => { + + // ── False-positive tests (must NOT match) ────────────────────────────── + + test('"Requirements" must NOT trigger UI gate (bug #3706 — "ui" substring)', () => { + const phaseSection = + '**Requirements**: The service must expose REST endpoints for authentication.\n' + + 'All work is server-side. Database migrations and API contract only.'; + assert.strictEqual(hasUiGate(phaseSection), 1, '"Requirements" must not match the UI gate'); + }); + + test('"overview" must NOT trigger UI gate ("view" is a substring)', () => { + assert.strictEqual(hasUiGate('Overview of the data pipeline architecture and backend services.'), 1); + }); + + test('"performance" must NOT trigger UI gate ("form" is a substring)', () => { + assert.strictEqual(hasUiGate('Performance testing and benchmark analysis for the API layer.'), 1); + }); + + test('"platform" must NOT trigger UI gate ("form" is a substring)', () => { + assert.strictEqual(hasUiGate('Deploy to the cloud platform and configure CI/CD.'), 1); + }); + + test('"transform" must NOT trigger UI gate ("form" is a substring)', () => { + assert.strictEqual(hasUiGate('Transform raw event data and write to the warehouse.'), 1); + }); + + test('"review" must NOT trigger UI gate ("view" is a substring)', () => { + assert.strictEqual(hasUiGate('Code review checklist and PR approval workflow for the API.'), 1); + }); + + test('"build" must NOT trigger UI gate ("ui" at positions 2-3 of "build")', () => { + assert.strictEqual(hasUiGate('Build the backend service and run integration tests.'), 1); + }); + + test('"screening" must NOT trigger UI gate ("screen" is a substring)', () => { + assert.strictEqual(hasUiGate('Implement candidate screening criteria for the hiring pipeline.'), 1); + }); + + test('empty input does NOT trigger UI gate', () => { + assert.strictEqual(hasUiGate(''), 1); + }); + + test('whitespace-only input does NOT trigger UI gate', () => { + assert.strictEqual(hasUiGate(' \n \t '), 1); + }); + + test('compound "microfrontend" (no separator) does NOT trigger gate — documented behavior', () => { + assert.strictEqual( + hasUiGate('Build the microfrontend shell application.'), 1, + '"microfrontend" compound must NOT trigger gate' + ); + }); + + // ── True-positive tests (must match) ────────────────────────────────── + + test('standalone "UI" token DOES trigger UI gate', () => { + assert.strictEqual(hasUiGate('UI Refactor: migrate all screens to the new design system.'), 0); + }); + + test('standalone "view" token DOES trigger UI gate', () => { + assert.strictEqual(hasUiGate('Implement the user profile view controller and associated screen.'), 0); + }); + + test('standalone "form" token DOES trigger UI gate', () => { + assert.strictEqual(hasUiGate('Build a sign-up form with client-side validation.'), 0); + }); + + test('"dashboard" DOES trigger UI gate', () => { + assert.strictEqual(hasUiGate('Build the analytics dashboard and navigation component.'), 0); + }); + + test('lowercase "ui" token DOES trigger UI gate (case-insensitive)', () => { + assert.strictEqual(hasUiGate('Redesign the ui for mobile responsiveness.'), 0); + }); + + test('"non-UI" hyphenated form DOES trigger UI gate (hyphen is a word boundary)', () => { + assert.strictEqual(hasUiGate('This is a non-UI backend service with no visual elements.'), 0); + }); + + test('standalone "screen" token DOES trigger UI gate', () => { + assert.strictEqual(hasUiGate('Implement the loading screen and splash animation.'), 0); + }); + + test('hyphenated "micro-frontend" DOES trigger gate (word boundary on hyphen)', () => { + assert.strictEqual(hasUiGate('Build the micro-frontend shell application.'), 0); + }); + + // ── CRLF / edge case tests ───────────────────────────────────────────── + + test('CRLF line endings: non-UI text does not trigger gate', () => { + assert.strictEqual(hasUiGate('Deploy to the cloud platform\r\nand configure CI/CD.'), 1); + }); + + test('CRLF line endings: UI token on second line triggers gate', () => { + assert.strictEqual(hasUiGate('Backend setup complete.\r\nBuild the analytics dashboard.'), 0); + }); + + test('leading/trailing whitespace does not affect token detection', () => { + assert.strictEqual(hasUiGate(' UI refactor phase '), 0); + }); + }); +} + +runBehavioralTests('ui-safety-gate.cjs'); + +// ── Install-dir resolution from a consuming project (#448) ──────────────────── + +describe('UI gate resolves the helper against RUNTIME_DIR, not the consuming repo (#448)', () => { + const REPO_ROOT = path.join(__dirname, '..'); + + // Mirrors the §5.6 / §3a.5 resolution. The structural guard above forces the + // workflows to keep using this RUNTIME_DIR-anchored form; this proves the + // candidate path is correct and the helper is actually found + executed when + // the CWD is a consuming project that has no bin/lib of its own. + const GATE_SNIPPET = [ + '_GSD_RT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"', + 'UI_GATE_JS=$(for _c in "$_GSD_RT/gsd-core/bin/lib/ui-safety-gate.cjs" "$_GSD_RT/bin/lib/ui-safety-gate.cjs" "$_GSD_RT/.claude/bin/lib/ui-safety-gate.cjs" "$HOME/.claude/gsd-core/bin/lib/ui-safety-gate.cjs" "$HOME/.claude/bin/lib/ui-safety-gate.cjs"; do [ -f "$_c" ] && { echo "$_c"; break; }; done)', + 'if [ -n "$UI_GATE_JS" ]; then printf \'%s\' "$PHASE_SECTION" | node "$UI_GATE_JS" >/dev/null 2>&1; HAS_UI=$?; else HAS_UI=0; fi', + 'echo "$HAS_UI"', + ].join('\n'); + + function runGateFrom(consumingDir, phaseSection) { + return spawnSync('bash', ['-c', GATE_SNIPPET], { + cwd: consumingDir, + encoding: 'utf-8', + env: { ...process.env, RUNTIME_DIR: REPO_ROOT, PHASE_SECTION: phaseSection }, + }); + } + + test('UI text is detected (HAS_UI=0) from a project without bin/lib', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-consuming-')); + try { + const res = runGateFrom(tmp, 'UI Refactor: migrate all screens'); + assert.strictEqual(res.status, 0, `bash failed: ${res.stderr}`); + assert.strictEqual(res.stdout.trim(), '0', + 'helper must be found via RUNTIME_DIR and report UI present — not silently no-op'); + } finally { + cleanup(tmp); + } + }); + + test('non-UI text returns HAS_UI=1 via the RUNTIME_DIR-resolved helper', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-consuming-')); + try { + const res = runGateFrom(tmp, 'Requirements: backend REST API only'); + assert.strictEqual(res.stdout.trim(), '1'); + } finally { + cleanup(tmp); + } + }); + + test('UI gate found via gsd-core/bin/lib/ in installed layout (no root bin/lib/)', () => { + // Regression for #448: installed RUNTIME_DIR has gsd-core/bin/lib/ but NOT root bin/lib/. + // The probe must find the helper at the installed path, not silently no-op to HAS_UI=0. + const fakeRuntime = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-installed-rt-')); + const consumingProject = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-consuming-')); + try { + const installedLibDir = path.join(fakeRuntime, 'gsd-core', 'bin', 'lib'); + fs.mkdirSync(installedLibDir, { recursive: true }); + fs.copyFileSync( + path.join(REPO_ROOT, 'gsd-core', 'bin', 'lib', 'ui-safety-gate.cjs'), + path.join(installedLibDir, 'ui-safety-gate.cjs') + ); + + const res = spawnSync('bash', ['-c', GATE_SNIPPET], { + cwd: consumingProject, + encoding: 'utf-8', + env: { ...process.env, RUNTIME_DIR: fakeRuntime, PHASE_SECTION: 'Build the analytics dashboard' }, + }); + assert.strictEqual(res.status, 0, `bash failed: ${res.stderr}`); + assert.strictEqual(res.stdout.trim(), '0', + 'helper must be found via gsd-core/bin/lib/ in installed layout and report UI present'); + } finally { + cleanup(fakeRuntime); + cleanup(consumingProject); + } + }); +}); + +// ── checkUiPresence() return value API ─────────────────────────────────────── + +describe('checkUiPresence() return value API', () => { + test('returns { hasUI: true, tokens: [...] } when UI found', () => { + const result = checkUiPresence('Build the dashboard and UI form'); + assert.strictEqual(result.hasUI, true); + assert.ok(Array.isArray(result.tokens), 'tokens must be an array'); + assert.ok(result.tokens.length > 0, 'tokens must be non-empty when hasUI is true'); + assert.ok(result.tokens.includes('dashboard') || result.tokens.includes('ui') || result.tokens.includes('form')); + }); + + test('returns { hasUI: false, tokens: [] } when no UI found', () => { + const result = checkUiPresence('Requirements: backend REST API only'); + assert.strictEqual(result.hasUI, false); + assert.deepStrictEqual(result.tokens, []); + }); + + test('tokens are lowercased', () => { + const result = checkUiPresence('UI Refactor'); + assert.ok(result.tokens.every(t => t === t.toLowerCase()), 'All tokens must be lowercase'); + }); + + test('tokens are deduplicated', () => { + const result = checkUiPresence('UI redesign and ui cleanup'); + const uiCount = result.tokens.filter(t => t === 'ui').length; + assert.strictEqual(uiCount, 1, 'Duplicate tokens must be deduplicated'); + }); + + test('non-string input returns { hasUI: false, tokens: [] }', () => { + assert.deepStrictEqual(checkUiPresence(null), { hasUI: false, tokens: [] }); + assert.deepStrictEqual(checkUiPresence(undefined), { hasUI: false, tokens: [] }); + assert.deepStrictEqual(checkUiPresence(42), { hasUI: false, tokens: [] }); + }); + + test('multiple distinct UI tokens on same line are ALL captured', () => { + // "form" and "view" both appear as standalone words on one line. + // Prior exec()-based impl only captured the first match per line. + const result = checkUiPresence('Build a sign-up form with a view controller'); + assert.strictEqual(result.hasUI, true, 'hasUI must be true'); + assert.ok(result.tokens.includes('form'), `Expected "form" in tokens, got: ${JSON.stringify(result.tokens)}`); + assert.ok(result.tokens.includes('view'), `Expected "view" in tokens, got: ${JSON.stringify(result.tokens)}`); + }); +}); + }); +} diff --git a/tests/clock-seam.test.cjs b/tests/clock-seam.test.cjs index c4fd34470..3b4e0e925 100644 --- a/tests/clock-seam.test.cjs +++ b/tests/clock-seam.test.cjs @@ -1123,3 +1123,291 @@ describe('roadmap analyze behavioral correctness (50-phase)', () => { assert.strictEqual(completedPhases.length, 25, `must have 25 complete phases, got ${completedPhases.length}`); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-474-clock-seam-date-determinism.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-474-clock-seam-date-determinism (consolidation epic #1969 B3 #1972)", () => { +// allow-test-rule: source-text-is-the-product (see #474) +// STATE.md is the product surface; assertions on its text content test the +// deployed contract (date field written by the subprocess SUT). + +'use strict'; + +/** + * Bug #474 — clock seam: subprocess date-stamping must be deterministic. + * + * Tests in this file verify that: + * 1. state.cjs date-stamping is routed through realClock (not bare new Date()), + * so GSD_NOW_MS pins the written date deterministically in subprocess tests. + * 2. installer-migrations.cjs lock-loop timeout fires deterministically via + * the clock seam (in-process, using makeFakeClock — no subprocess needed). + */ + +const { describe, test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const { runGsdTools, cleanup } = require('./helpers.cjs'); +const { createFixture } = require('./fixtures/index.cjs'); +const { makeFakeClock } = require('./helpers/clock.cjs'); + +// ───────────────────────────────────────────────────────────────────────────── +// §1 Subprocess date-pin: state advance-plan writes the pinned date +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Why advance-plan? + * cmdStateAdvancePlan captures `const today = new Date().toISOString().split('T')[0]` + * and writes it to "Last Activity" via stateReplaceFieldIfTemplate. + * The fixture below has Last Activity = 2024-01-10 (an ISO date, treated as a + * handler-generated template default by isStateTemplateDefault), so the field IS + * overwritten — making this the simplest single-subcommand probe of the bug. + */ + +// A fixed historical instant far in the past — will NEVER match today's real date. +const PINNED_MS = Date.parse('2020-06-15T12:00:00.000Z'); +const PINNED_DATE = '2020-06-15'; + +// A minimal STATE.md that satisfies advance-plan's parser: +// - Current Plan: 1 (not on last plan → normal-advance branch) +// - Total Plans in Phase: 3 +// - Last Activity: 2024-01-10 (ISO date → isStateTemplateDefault returns true → will be replaced) +const ADVANCE_FIXTURE = [ + '# Project State', + '', + '**Current Plan:** 1', + '**Total Plans in Phase:** 3', + '**Status:** Executing', + '**Last Activity:** 2024-01-10', +].join('\n') + '\n'; + +describe('bug-474: state date-stamping is pinned by GSD_NOW_MS', () => { + let tmpDir; + + before(() => { + // AAA — Arrange: create a temp project with the advance fixture + tmpDir = createFixture(); + fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), ADVANCE_FIXTURE); + }); + + after(() => { + cleanup(tmpDir); + }); + + test('state advance-plan writes pinned date (not real today) when GSD_NOW_MS is set', () => { + // AAA — Act: run advance-plan with a pinned historical timestamp + const result = runGsdTools('state advance-plan', tmpDir, { + GSD_TEST_MODE: '1', + GSD_NOW_MS: String(PINNED_MS), + }); + + assert.ok(result.success, `advance-plan failed unexpectedly: ${result.error}`); + + // AAA — Assert: the written STATE.md must contain the pinned date + const written = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); + + // Must contain the pinned historical date (2020-06-15) + assert.ok( + written.includes(PINNED_DATE), + `Expected STATE.md to contain pinned date ${PINNED_DATE}.\nActual STATE.md:\n${written}`, + ); + + // Must NOT contain today's real date — that would mean the seam is bypassed + const realToday = new Date().toISOString().split('T')[0]; + assert.ok( + !written.includes(realToday), + `Expected STATE.md NOT to contain real today (${realToday}) when time is pinned.\nActual STATE.md:\n${written}`, + ); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// §2 realClock GSD_NOW_MS invalid-input hardening +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Verify that malformed / out-of-range GSD_NOW_MS values fall back to the real + * clock instead of crashing with RangeError (issue #474 hardening). + * + * Each invalid input must: + * a) not throw from realClock.nowIso(), and + * b) produce a valid parseable ISO string (i.e. fell back to Date.now()). + * + * A valid pinned value must produce the expected date string. + */ + +describe('bug-474: realClock GSD_NOW_MS invalid-input hardening', () => { + const realClock = require('../gsd-core/bin/lib/clock.cjs').realClock; + + // Save and restore env so these tests cannot bleed into neighbouring tests. + let savedTestMode; + let savedNowMs; + + before(() => { + savedTestMode = process.env.GSD_TEST_MODE; + savedNowMs = process.env.GSD_NOW_MS; + process.env.GSD_TEST_MODE = '1'; + }); + + after(() => { + if (savedTestMode === undefined) { + delete process.env.GSD_TEST_MODE; + } else { + process.env.GSD_TEST_MODE = savedTestMode; + } + if (savedNowMs === undefined) { + delete process.env.GSD_NOW_MS; + } else { + process.env.GSD_NOW_MS = savedNowMs; + } + }); + + // AAA matrix: each invalid value must NOT crash and must fall back to the real clock. + const INVALID_INPUTS = [ + ['empty string', ''], + ['whitespace only', ' '], + ['alphabetic', 'abc'], + ['scientific notation', '1e30'], + ['decimal float', '12.5'], + ['integer > 8.64e15', '99999999999999999999'], + ]; + + for (const [label, value] of INVALID_INPUTS) { + test(`GSD_NOW_MS='${value}' (${label}) falls back to real clock — no crash, valid ISO`, () => { + // AAA — Arrange + process.env.GSD_NOW_MS = value; + + // AAA — Act + Assert: must not throw + assert.doesNotThrow( + () => realClock.nowIso(), + `realClock.nowIso() must not throw for GSD_NOW_MS='${value}'`, + ); + + // AAA — Assert: result is a valid ISO string (fell back to real clock) + const iso = realClock.nowIso(); + assert.ok( + !Number.isNaN(Date.parse(iso)), + `realClock.nowIso() must return a valid ISO date for GSD_NOW_MS='${value}', got: ${iso}`, + ); + }); + } + + test('GSD_NOW_MS valid decimal integer pins the clock', () => { + // AAA — Arrange: a known historical epoch + process.env.GSD_NOW_MS = String(PINNED_MS); + + // AAA — Act + const tod = realClock.today(); + + // AAA — Assert + assert.strictEqual(tod, PINNED_DATE, `realClock.today() must return pinned date ${PINNED_DATE}`); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// §3 In-process lock-loop: installer-migrations timeout fires deterministically +// ───────────────────────────────────────────────────────────────────────────── + +/** + * This test exercises acquireInstallMigrationLock's EEXIST retry loop via a + * makeFakeClock. The approach: + * - Write a lock file held by PID 1 (init/launchd — always alive on any OS) + * so neither isSameProcess nor isDeadProcess is true; stale-lock reclamation + * is NOT triggered, and the loop must reach the timeout check. + * - Use TIMEOUT_MS = 500 (non-zero) so the loop must retry at least once before + * the injected clock trips the deadline. A zero timeout would fire on the + * very first check without exercising clock.sleep() at all. + * - The fake clock's sleep(ms) ADVANCES its internal now by ms (confirmed from + * helpers/clock.cjs implementation), so the loop drives itself to termination + * purely through the injected clock without any wall-clock delay. + * - Post-throw assertions on clock.sleepCalls and clock.now() prove the seam: + * if the loop reverted to raw Date.now()/sleepSync, sleepCalls would be 0. + */ + +describe('bug-474: installer-migrations lock-loop timeout is deterministic via clock seam', () => { + test('makeFakeClock nowIso() and today() derive from pinned now()', () => { + // AAA — Arrange + const clock = makeFakeClock(PINNED_MS); + + // AAA — Act + const iso = clock.nowIso(); + const tod = clock.today(); + + // AAA — Assert + assert.strictEqual(iso, '2020-06-15T12:00:00.000Z', 'nowIso() must return ISO string of pinned epoch'); + assert.strictEqual(tod, PINNED_DATE, 'today() must return YYYY-MM-DD of pinned epoch'); + }); + + test('makeFakeClock advance() shifts nowIso() and today()', () => { + // AAA — Arrange: start at PINNED_MS, advance by 24 h + const clock = makeFakeClock(PINNED_MS); + const ONE_DAY_MS = 24 * 60 * 60 * 1000; + + // AAA — Act + clock.advance(ONE_DAY_MS); + + // AAA — Assert + assert.strictEqual(clock.today(), '2020-06-16', 'today() must reflect advanced time'); + }); + + test('acquireInstallMigrationLock timeout path fires deterministically via makeFakeClock', (t) => { + // AAA — Arrange + const os = require('os'); + const { acquireInstallMigrationLock } = require('../gsd-core/bin/lib/installer-migrations.cjs'); + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-474-lock-')); + + t.after(() => { + cleanup(configDir); + }); + + const LOCK_NAME = 'gsd-install-migration.lock'; + const lockPath = path.join(configDir, LOCK_NAME); + + // Write a lock file held by process.ppid (the parent process, always alive + // and never equal to process.pid on every platform). pid 1 was used + // previously but isPidAlive(1) returns false on Windows (no init/launchd + // pid-1 concept), so the lock was treated as stale, reclaimed, and + // acquireInstallMigrationLock succeeded instead of throwing — failing the + // timeout assertion on windows-latest,22 (#474). process.ppid is a live, + // non-self process on all platforms, so the lock is correctly seen as held + // and the timeout path throws deterministically cross-platform. + fs.writeFileSync( + lockPath, + JSON.stringify({ pid: process.ppid, acquiredAt: new Date().toISOString() }) + '\n', + ); + + // TIMEOUT_MS is non-zero so the first EEXIST iteration does NOT immediately + // trip the deadline. The loop must call clock.sleep() at least once; that + // sleep() advances the fake clock past TIMEOUT_MS, causing the next check to + // throw. This proves the seam: if the loop used raw Date.now()/sleepSync, + // clock.sleepCalls would remain 0. + const TIMEOUT_MS = 500; + const clock = makeFakeClock(0); + + // AAA — Act + Assert: timeout error thrown with no real wall-clock delay + assert.throws( + () => acquireInstallMigrationLock(configDir, { timeoutMs: TIMEOUT_MS }, clock), + /lock|held/i, + 'Expected acquireInstallMigrationLock to throw with "lock"/"held" in message on timeout', + ); + + // AAA — Assert seam was actually exercised through the injected clock: + // If the loop used raw sleepSync instead of clock.sleep(), sleepCalls would be 0. + assert.ok( + clock.sleepCalls.length > 0, + 'loop must retry via injected clock.sleep() — proves seam wiring, not raw wall clock', + ); + + // The injected clock must have advanced past TIMEOUT_MS through its own sleep() calls. + assert.ok( + clock.now() >= TIMEOUT_MS, + `injected clock advanced past timeout deterministically: clock.now()=${clock.now()} must be >= TIMEOUT_MS=${TIMEOUT_MS}`, + ); + }); +}); + }); +} diff --git a/tests/code-review-flags.test.cjs b/tests/code-review-flags.test.cjs index 68fad7e09..cf68faed5 100644 --- a/tests/code-review-flags.test.cjs +++ b/tests/code-review-flags.test.cjs @@ -105,3 +105,177 @@ describe('resolveCodeReviewWorkflow', () => { ); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3727-code-review-fix-flag-dispatch.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3727-code-review-fix-flag-dispatch (consolidation epic #1969 B3 #1972)", () => { +// allow-test-rule: source-text-is-the-product (see #3727) +// The workflow .md IS the product: its text is loaded and interpreted at +// runtime by the agent host. Structural assertions (step existence, flag +// references in the initialize step) verify the deployed dispatch contract. +// Pure-function assertions on parseCodeReviewFlags / resolveCodeReviewWorkflow +// are IR-level (not raw-text-match) per CONTRIBUTING.md L554. + +/** + * Regression tests for #3727 — /gsd-code-review N --fix silently no-ops. + * + * Root cause: code-review.md `initialize` step parses only --depth and + * --files flags. --fix, --all, and --auto are never parsed, so no dispatch + * to code-review-fix.md ever occurs. + * + * Fix: add parseCodeReviewFlags() to code-review-flags.cjs (typed IR), wire + * it into the workflow initialize step, and add a dispatch_fix step that + * delegates to code-review-fix.md when flags.fix is true. + */ + +'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 ROOT = path.resolve(__dirname, '..'); +const FLAGS_LIB = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'code-review-flags.cjs'); +const WORKFLOW_PATH = path.join(ROOT, 'gsd-core', 'workflows', 'code-review.md'); + +// --------------------------------------------------------------------------- +// Stage 2 seam: typed IR from parseCodeReviewFlags and resolveCodeReviewWorkflow +// These functions are the production code under test — not text-matching on +// the .md source. +// --------------------------------------------------------------------------- + +const { parseCodeReviewFlags, resolveCodeReviewWorkflow } = require(FLAGS_LIB); + +describe('#3727 — parseCodeReviewFlags: typed IR for code-review argv', () => { + test('code-review workflow parses --fix flag and dispatches gsd-code-fixer', () => { + // This is the invariant being violated: --fix must be captured into the IR + // and must route to the fixer workflow, not be silently dropped. + const flags = parseCodeReviewFlags(['2', '--fix']); + assert.strictEqual(flags.fix, true, + '--fix must set flags.fix = true'); + const workflow = resolveCodeReviewWorkflow(flags); + assert.strictEqual(workflow, 'code-review-fix.md', + '--fix must dispatch to code-review-fix.md, not code-review.md'); + }); + + test('--fix absent: dispatch stays on code-review.md (review-only)', () => { + // Counter-test (Stage 5 contract 6): omitting --fix must NOT dispatch fixer. + const flags = parseCodeReviewFlags(['2']); + assert.strictEqual(flags.fix, false, + 'Without --fix, flags.fix must be false'); + const workflow = resolveCodeReviewWorkflow(flags); + assert.strictEqual(workflow, 'code-review.md', + 'Without --fix, dispatch must stay on code-review.md'); + }); + + test('--all implies --fix', () => { + const flags = parseCodeReviewFlags(['3', '--all']); + assert.strictEqual(flags.fix, true, + '--all must imply flags.fix = true'); + assert.strictEqual(flags.all, true, + '--all must set flags.all = true'); + const workflow = resolveCodeReviewWorkflow(flags); + assert.strictEqual(workflow, 'code-review-fix.md', + '--all must dispatch to code-review-fix.md'); + }); + + test('--auto implies --fix', () => { + const flags = parseCodeReviewFlags(['3', '--auto']); + assert.strictEqual(flags.fix, true, + '--auto must imply flags.fix = true'); + assert.strictEqual(flags.auto, true, + '--auto must set flags.auto = true'); + const workflow = resolveCodeReviewWorkflow(flags); + assert.strictEqual(workflow, 'code-review-fix.md', + '--auto must dispatch to code-review-fix.md'); + }); + + test('--fix --all --auto all set simultaneously', () => { + const flags = parseCodeReviewFlags(['1', '--fix', '--all', '--auto']); + assert.strictEqual(flags.fix, true); + assert.strictEqual(flags.all, true); + assert.strictEqual(flags.auto, true); + assert.strictEqual(resolveCodeReviewWorkflow(flags), 'code-review-fix.md'); + }); + + test('--depth flag is still parsed alongside --fix (no regression)', () => { + const flags = parseCodeReviewFlags(['2', '--depth=deep', '--fix']); + assert.strictEqual(flags.fix, true); + assert.strictEqual(flags.depth, 'deep'); + assert.strictEqual(resolveCodeReviewWorkflow(flags), 'code-review-fix.md'); + }); + + test('--files flag is still parsed alongside --fix (no regression)', () => { + const flags = parseCodeReviewFlags(['2', '--files=src/foo.ts,src/bar.ts', '--fix']); + assert.strictEqual(flags.fix, true); + assert.strictEqual(flags.files, 'src/foo.ts,src/bar.ts'); + }); + + test('empty argv returns all-false IR', () => { + const flags = parseCodeReviewFlags([]); + assert.strictEqual(flags.fix, false); + assert.strictEqual(flags.all, false); + assert.strictEqual(flags.auto, false); + assert.strictEqual(flags.depth, ''); + assert.strictEqual(flags.files, ''); + }); +}); + +// --------------------------------------------------------------------------- +// Docs-parity: the workflow .md must contain the dispatch_fix step and +// reference code-review-flags.cjs in the initialize step. +// These are structural invariants on the deployed product text. +// Source-text-is-the-product exemption applies (see file header). +// --------------------------------------------------------------------------- + +describe('#3727 — code-review.md structural dispatch contract', () => { + const src = fs.readFileSync(WORKFLOW_PATH, 'utf8'); + + test('initialize step references code-review-flags.cjs for flag parsing', () => { + const initStart = src.indexOf(''); + const initEnd = src.indexOf('', initStart); + assert.ok(initStart !== -1, 'workflow must have an initialize step'); + const initSection = src.slice(initStart, initEnd); + assert.ok( + initSection.includes('code-review-flags.cjs'), + 'initialize step must reference code-review-flags.cjs to parse --fix/--all/--auto flags' + ); + }); + + test('workflow has a step', () => { + assert.ok( + src.includes(''), + 'code-review.md must have a dispatch_fix step (missing = --fix is silently no-op)' + ); + }); + + test('dispatch_fix step delegates to code-review-fix.md when fix flag is true', () => { + const stepStart = src.indexOf(''); + const stepEnd = src.indexOf('', stepStart); + assert.ok(stepStart !== -1, 'dispatch_fix step must exist'); + const stepSection = src.slice(stepStart, stepEnd); + assert.ok( + stepSection.includes('code-review-fix.md'), + 'dispatch_fix step must reference code-review-fix.md workflow' + ); + }); + + test('dispatch_fix step references gsd-code-fixer agent (via code-review-fix.md chain)', () => { + // The dispatch step must show that fixing will occur, either directly or + // by loading code-review-fix.md (which in turn spawns gsd-code-fixer). + const stepStart = src.indexOf(''); + const stepEnd = src.indexOf('', stepStart); + const stepSection = src.slice(stepStart, stepEnd); + assert.ok( + stepSection.includes('gsd-code-fixer') || stepSection.includes('code-review-fix.md'), + 'dispatch_fix step must reference gsd-code-fixer agent or code-review-fix.md workflow' + ); + }); +}); + }); +} diff --git a/tests/command-arg-projection.test.cjs b/tests/command-arg-projection.test.cjs index 9d5701e29..c3489b043 100644 --- a/tests/command-arg-projection.test.cjs +++ b/tests/command-arg-projection.test.cjs @@ -121,3 +121,41 @@ test('parseMultiwordArg: flag at end of array with no tokens returns null', () = null ); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3431-debug-command-yaml.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3431-debug-command-yaml (consolidation epic #1969 B3 #1972)", () => { +// allow-test-rule: source-text-is-the-product (see #3431) +// Command markdown frontmatter is the deployed contract; this regression test +// verifies the real YAML surface that external parsers consume. + +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { parseFrontmatter } = require('./helpers.cjs'); + +const DEBUG_COMMAND_PATH = path.join(__dirname, '..', 'commands', 'gsd', 'debug.md'); + +function readFrontmatter(filePath) { + return parseFrontmatter(fs.readFileSync(filePath, 'utf8')); +} + +test('#3431: commands/gsd/debug.md frontmatter parses as YAML and preserves argument-hint', () => { + const frontmatter = readFrontmatter(DEBUG_COMMAND_PATH); + + assert.equal(frontmatter.name, 'gsd:debug'); + assert.equal( + frontmatter['argument-hint'], + '[list | status | continue | --diagnose] [issue description]', + 'argument-hint should remain user-visible text after YAML parsing' + ); +}); + }); +} diff --git a/tests/command-contract.test.cjs b/tests/command-contract.test.cjs index 4240bca57..e9fd20281 100644 --- a/tests/command-contract.test.cjs +++ b/tests/command-contract.test.cjs @@ -112,3 +112,127 @@ describe('command contract: execution_context @-refs on own line (ADR-0002)', () }); } }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3168-task-to-agent-rename.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3168-task-to-agent-rename (consolidation epic #1969 B3 #1972)", () => { +'use strict'; + +// allow-test-rule: source-text-is-the-product (see #3168) +// commands/gsd/*.md, gsd-core/workflows/*.md, and agents/gsd-*.md are +// deployed product files. Checking their text IS checking the runtime contract. + +/** + * #3168 — Incomplete Task→Agent dispatcher rename causes silent inline fallback. + * + * The Claude Code subagent-dispatcher tool is named `Agent`. The `Task*` namespace + * (TaskCreate, TaskList, TaskGet, TaskUpdate, TaskOutput, TaskStop) is the task + * tracker — a distinct tool set. GSD workflows were partially migrated and still + * reference `Task(` and `- Task` in allowed-tools/tools frontmatter in most files. + */ + +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 COMMANDS_DIR = path.join(ROOT, 'commands', 'gsd'); +const WORKFLOWS_DIR = path.join(ROOT, 'gsd-core', 'workflows'); +const AGENTS_DIR = path.join(ROOT, 'agents'); + +// Task tracker names — these must NOT be renamed +const TASK_TRACKER_PATTERN = /\bTask(?:Create|List|Get|Update|Output|Stop)\b/; + +function readMdFiles(dir, prefix) { + return fs.readdirSync(dir) + .filter(f => f.endsWith('.md') && (!prefix || f.startsWith(prefix))) + .map(f => ({ name: f, path: path.join(dir, f), content: fs.readFileSync(path.join(dir, f), 'utf-8') })); +} + +function extractFrontmatterTools(content) { + const fm = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!fm) return []; + const toolsMatch = fm[1].match(/^allowed-tools:\s*\r?\n((?:[ \t]+-[^\n]*\n?)*)/m) || + fm[1].match(/^tools:\s*(.+)$/m); + if (!toolsMatch) return []; + const toolsBlock = toolsMatch[1]; + if (toolsBlock.includes('\n')) { + return toolsBlock.match(/[-\s]*([A-Za-z_*][A-Za-z0-9_*]*)/g) + .map(t => t.replace(/^[-\s]+/, '').trim()) + .filter(Boolean); + } + return toolsBlock.split(',').map(t => t.trim()).filter(Boolean); +} + +describe('#3168 — commands/gsd: allowed-tools must use Agent not Task', () => { + const commands = readMdFiles(COMMANDS_DIR); + + for (const cmd of commands) { + test(`${cmd.name}: allowed-tools must not list Task without Agent`, () => { + const tools = extractFrontmatterTools(cmd.content); + const hasTask = tools.includes('Task'); + const hasAgent = tools.includes('Agent'); + assert.ok( + !hasTask || hasAgent, + `${cmd.name}: allowed-tools lists "Task" but not "Agent" — dispatcher tool is "Agent", not "Task"\n tools: [${tools.join(', ')}]`, + ); + assert.ok( + !hasTask, + `${cmd.name}: allowed-tools still lists "Task" — remove it (Agent is the dispatcher tool)\n tools: [${tools.join(', ')}]`, + ); + }); + } +}); + +describe('#3168 — workflows: prose must use Agent( not Task( for dispatcher calls', () => { + const workflows = []; + function collectMd(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) collectMd(path.join(dir, entry.name)); + else if (entry.name.endsWith('.md')) workflows.push({ name: entry.name, path: path.join(dir, entry.name), content: fs.readFileSync(path.join(dir, entry.name), 'utf-8') }); + } + } + collectMd(WORKFLOWS_DIR); + + for (const wf of workflows) { + test(`${wf.name}: must not contain dispatcher Task( calls`, () => { + const lines = wf.content.split(/\r?\n/); + const violations = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + // Skip code fences that show old examples + if (line.trim().startsWith('```') || line.trim().startsWith('#')) continue; + // Match Task( that is NOT a tracker call (TaskCreate, TaskList, etc.) + if (/\bTask\(/.test(line) && !TASK_TRACKER_PATTERN.test(line)) { + violations.push(` line ${i + 1}: ${line.trim()}`); + } + } + assert.deepStrictEqual( + violations, + [], + `${wf.name}: found dispatcher Task( calls that should be Agent(:\n${violations.join('\n')}`, + ); + }); + } +}); + +describe('#3168 — agents: tools frontmatter must use Agent not Task', () => { + const agents = readMdFiles(AGENTS_DIR, 'gsd-'); + + for (const agent of agents) { + test(`${agent.name}: tools must not list Task`, () => { + const tools = extractFrontmatterTools(agent.content); + assert.ok( + !tools.includes('Task'), + `${agent.name}: tools frontmatter lists "Task" — should be "Agent"\n tools: [${tools.join(', ')}]`, + ); + }); + } +}); + }); +} diff --git a/tests/command-routing-hub.test.cjs b/tests/command-routing-hub.test.cjs index fac68338d..67170f2d2 100644 --- a/tests/command-routing-hub.test.cjs +++ b/tests/command-routing-hub.test.cjs @@ -1801,3 +1801,82 @@ describe('dispatch-should-flatten query — behavioral', () => { }); }); } + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3683-command-cross-reference-invariant.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3683-command-cross-reference-invariant (consolidation epic #1969 B3 #1972)", () => { +// allow-test-rule: source-text-is-the-product (see #3683) +// commands/gsd/*.md bodies are the deployed contract — cross-references between +// them must stay coherent. This test inspects .md source to enforce the invariant. + +'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 COMMANDS_DIR = path.resolve(__dirname, '..', 'commands', 'gsd'); + +function readKnownTargets() { + const commandNames = fs.readdirSync(COMMANDS_DIR) + .filter(f => f.endsWith('.md')) + .map(f => f.slice(0, -3)); + return { commandNames, knownTargets: new Set(commandNames) }; +} + +function stripFrontmatter(src) { + return src.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, ''); +} + +// Word-boundary lookbehind matching fix-slash-commands.cjs buildColonPattern / buildPattern +// Excludes path-y characters (~, ., /) so `~/gsd-workspaces`, `./gsd-foo`, `path/gsd-bar` don't match. +// Trailing `(?![\w-]*\/)` rejects filesystem path segments like `${VAR}/gsd-core/bin` (the +// runtime-launcher shim) where a non-path char (e.g. `}`) precedes `/gsd-core/` — those are +// directory paths to the gsd-core/ runtime, not slash-command references (#604 rename). +const REF_PATTERN = /(? { + test('all /gsd: and /gsd- body refs resolve to known command base-names', () => { + const { commandNames, knownTargets: knownSet } = readKnownTargets(); + const mdFiles = commandNames.sort().map(n => path.join(COMMANDS_DIR, `${n}.md`)); + + const failures = []; + + for (const filePath of mdFiles) { + const src = fs.readFileSync(filePath, 'utf-8'); + const body = stripFrontmatter(src); + const lines = body.split('\n'); + const relFile = path.relative(path.resolve(__dirname, '..'), filePath); + + lines.forEach((line, idx) => { + REF_PATTERN.lastIndex = 0; + let m; + while ((m = REF_PATTERN.exec(line)) !== null) { + const ref = m[1]; + if (!knownSet.has(ref)) { + const sep = m[0].includes(':') ? ':' : '-'; + failures.push({ + file: relFile, + line: idx + 1, + ref: `/gsd${sep}${ref}`, + excerpt: line.trim(), + }); + } + } + }); + } + + if (failures.length > 0) { + const msg = failures + .map(f => ` ${f.file}:${f.line} — dangling ref "${f.ref}" — ${f.excerpt}`) + .join('\n'); + assert.fail(`Dangling command cross-references found:\n${msg}`); + } + }); +}); + }); +} diff --git a/tests/commands.test.cjs b/tests/commands.test.cjs index 7df591603..97809d415 100644 --- a/tests/commands.test.cjs +++ b/tests/commands.test.cjs @@ -2778,3 +2778,672 @@ describe('pr-subrepo', () => { }); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/feat-1754-cli-skew-detection.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:feat-1754-cli-skew-detection (consolidation epic #1969 B3 #1972)", () => { +'use strict'; + +/** + * feat-1754-cli-skew-detection.test.cjs + * + * Tests for the CLI version-skew detection module (src/cli-skew-check.cts). + * + * The check warns (returns a string) when the running gsd-tools.cjs is NOT the + * project-local install while a project-local install EXISTS — the shadowing + * scenario from #1748 (a stale global canary from @gsd-build/sdk shadowing + * project-local 1.6.0). + * + * DEFECT class: environment / version skew (enhancement #1754) + * + * The function is PURE (no I/O — the caller provides paths + existence flags), + * making it trivially testable without filesystem setup. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const { checkCliSkew } = require('../gsd-core/bin/lib/cli-skew-check.cjs'); + +describe('#1754: checkCliSkew — pure path-comparison skew detection', () => { + test('SKEW: resolved CLI outside project root + project-local exists → returns warning', () => { + const warning = checkCliSkew({ + resolvedPath: '/opt/homebrew/bin/gsd-tools', + projectRoot: '/home/user/my-project', + projectLocalExists: true, + }); + assert.ok(warning, 'Expected a warning string when resolved CLI is outside project root and project-local exists'); + assert.ok(warning.includes('shadow') || warning.includes('outside') || warning.includes('may'), + `Warning should mention the shadowing/outside nature, got: "${warning}"`); + }); + + test('NO-SKEW: resolved CLI is the project-local install → returns null', () => { + const warning = checkCliSkew({ + resolvedPath: '/home/user/my-project/.claude/gsd-core/bin/gsd-tools.cjs', + projectRoot: '/home/user/my-project', + projectLocalExists: true, + }); + assert.strictEqual(warning, null, 'No warning expected when resolved CLI IS the project-local install'); + }); + + test('NO-SKEW: resolved CLI outside project root but NO project-local install → returns null', () => { + const warning = checkCliSkew({ + resolvedPath: '/usr/local/bin/gsd-tools', + projectRoot: '/home/user/my-project', + projectLocalExists: false, + }); + assert.strictEqual(warning, null, 'No warning expected when no project-local install exists (legitimate global-only)'); + }); + + test('NO-SKEW: projectRoot is null (no project context) → returns null', () => { + const warning = checkCliSkew({ + resolvedPath: '/usr/local/bin/gsd-tools', + projectRoot: null, + projectLocalExists: false, + }); + assert.strictEqual(warning, null, 'No warning expected when there is no project root'); + }); + + test('LEGACY-SDK: resolved path contains @gsd-build → warning includes removal instructions', () => { + const warning = checkCliSkew({ + resolvedPath: '/opt/homebrew/lib/node_modules/@gsd-build/sdk/bin/gsd-tools', + projectRoot: '/home/user/my-project', + projectLocalExists: true, + }); + assert.ok(warning, 'Expected a warning for @gsd-build/sdk paths'); + assert.ok(warning.includes('@gsd-build/sdk') || warning.includes('npm uninstall'), + `Warning should include @gsd-build/sdk removal instructions, got: "${warning}"`); + }); + + test('PATH-NORMALIZATION: resolved under project root via realpath → no false positive', () => { + // Even if the resolved path differs in symlink resolution, if it's under the + // project root, it's not a skew. The caller normalizes paths before calling. + const warning = checkCliSkew({ + resolvedPath: path.resolve('/home/user/my-project/.claude/gsd-core/bin/gsd-tools.cjs'), + projectRoot: path.resolve('/home/user/my-project'), + projectLocalExists: true, + }); + assert.strictEqual(warning, null, 'No warning when resolved path is under project root (even with realpath normalization)'); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/feat-3251-command-aliases-manifest-coverage.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:feat-3251-command-aliases-manifest-coverage (consolidation epic #1969 B3 #1972)", () => { +'use strict'; +/** + * Regression guard for issue #3251: + * 14 commands used in workflows must be present in command-aliases.cjs. + * + * Asserts structurally by requiring the manifest and checking each canonical + * command appears in either the family arrays or the non-family array. + * Never greps the source file — see feedback_no_source_grep_tests.md. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('path'); +const { spawnSync } = require('node:child_process'); +const { cleanup } = require('./helpers.cjs'); + +const REPO_ROOT = path.join(__dirname, '..'); +const COMMAND_ALIASES_FILE = path.join( + REPO_ROOT, + 'gsd-core', + 'bin', + 'lib', + 'command-aliases.cjs', +); +const GSD_TOOLS = path.join(REPO_ROOT, 'gsd-core', 'bin', 'gsd-tools.cjs'); + +const MISSING_14 = [ + 'check.decision-coverage-plan', + 'check.decision-coverage-verify', + 'frontmatter.get', + 'frontmatter.set', + 'learnings.copy', + 'milestone.complete', + 'phase.mvp-mode', + 'progress.bar', + 'requirements.mark-complete', + 'stats.json', + 'task.is-behavior-adding', + 'todo.match-phase', + 'uat.render-checkpoint', + 'workstream.list', +]; + +describe('feat-3251: command-aliases.cjs manifest coverage', () => { + let manifest; + + test('manifest file can be required without error', () => { + try { + manifest = require(COMMAND_ALIASES_FILE); + } catch (err) { + assert.fail(`Failed to require manifest: ${err.message}`); + } + assert.ok(manifest, 'manifest should be truthy'); + }); + + test('manifest exports NON_FAMILY_COMMAND_ALIASES array', () => { + manifest = manifest ?? require(COMMAND_ALIASES_FILE); + assert.ok( + Array.isArray(manifest.NON_FAMILY_COMMAND_ALIASES), + 'NON_FAMILY_COMMAND_ALIASES must be an exported array in command-aliases.cjs', + ); + }); + + test('all 14 missing commands are present in the manifest (family or non-family)', () => { + manifest = manifest ?? require(COMMAND_ALIASES_FILE); + + const allCanonicalsInManifest = new Set(); + + // Collect from all family arrays + const familyArrayKeys = [ + 'STATE_COMMAND_ALIASES', + 'VERIFY_COMMAND_ALIASES', + 'INIT_COMMAND_ALIASES', + 'PHASE_COMMAND_ALIASES', + 'PHASES_COMMAND_ALIASES', + 'VALIDATE_COMMAND_ALIASES', + 'ROADMAP_COMMAND_ALIASES', + 'EVAL_COMMAND_ALIASES', + ]; + for (const key of familyArrayKeys) { + const arr = manifest[key]; + if (!Array.isArray(arr)) continue; + for (const entry of arr) { + if (entry && entry.canonical) allCanonicalsInManifest.add(entry.canonical); + } + } + + // Collect from non-family array + const nonFamily = manifest.NON_FAMILY_COMMAND_ALIASES; + if (Array.isArray(nonFamily)) { + for (const entry of nonFamily) { + if (entry && entry.canonical) allCanonicalsInManifest.add(entry.canonical); + } + } + + const missing = MISSING_14.filter((cmd) => !allCanonicalsInManifest.has(cmd)); + assert.deepStrictEqual( + missing, + [], + `${missing.length} command(s) still missing from manifest: ${missing.join(', ')}`, + ); + }); + + test('each non-family entry has required fields: canonical, aliases, mutation', () => { + manifest = manifest ?? require(COMMAND_ALIASES_FILE); + const nonFamily = manifest.NON_FAMILY_COMMAND_ALIASES; + if (!Array.isArray(nonFamily)) return; // caught by earlier test + + for (const entry of nonFamily) { + assert.ok(typeof entry.canonical === 'string' && entry.canonical.length > 0, + `entry missing canonical: ${JSON.stringify(entry)}`); + assert.ok(Array.isArray(entry.aliases), + `entry missing aliases array for canonical=${entry.canonical}`); + assert.ok(typeof entry.mutation === 'boolean', + `entry missing mutation boolean for canonical=${entry.canonical}`); + } + }); + + test('NON_FAMILY_COMMAND_ALIASES is sorted by canonical (deterministic output)', () => { + manifest = manifest ?? require(COMMAND_ALIASES_FILE); + const nonFamily = manifest.NON_FAMILY_COMMAND_ALIASES; + if (!Array.isArray(nonFamily)) return; // caught by earlier test + + const canonicals = nonFamily.map((e) => e.canonical); + const sorted = [...canonicals].sort((a, b) => a.localeCompare(b)); + assert.deepStrictEqual( + canonicals, + sorted, + 'NON_FAMILY_COMMAND_ALIASES must be sorted by canonical for deterministic regeneration', + ); + }); +}); + +function createProject() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3251-dispatch-')); + fs.mkdirSync(path.join(dir, '.planning', 'phases'), { recursive: true }); + return dir; +} + +function runGsdTools(args, projectDir) { + return spawnSync(process.execPath, [GSD_TOOLS, ...args], { + cwd: projectDir, + encoding: 'utf8', + timeout: 30000, + killSignal: 'SIGKILL', + }); +} + +function snapshotProjectState(projectDir) { + const files = []; + function walk(dir) { + if (!fs.existsSync(dir)) return; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + const rel = path.relative(projectDir, full); + if (entry.isDirectory()) walk(full); + else { + files.push({ + path: rel, + sha256: crypto.createHash('sha256').update(fs.readFileSync(full)).digest('hex'), + }); + } + } + } + walk(projectDir); + return files.sort((a, b) => a.path.localeCompare(b.path)); +} + +describe('feat-3251: generated aliases dispatch through real gsd-tools behavior', () => { + test('phase.mvp-mode spaced alias resolves CLI flag precedence', () => { + const projectDir = createProject(); + try { + const result = runGsdTools(['phase', 'mvp-mode', '1', '--cli-flag'], projectDir); + assert.equal(result.status, 0, result.stderr); + + const output = JSON.parse(result.stdout); + assert.deepEqual(output, { + active: true, + source: 'cli_flag', + roadmap_mode: null, + config_mvp_mode: false, + cli_flag_present: true, + }); + } finally { + cleanup(projectDir); + } + }); + + test('phase.mvp-mode spaced alias resolves ROADMAP mode without mutating files', () => { + const projectDir = createProject(); + try { + fs.writeFileSync( + path.join(projectDir, '.planning', 'ROADMAP.md'), + [ + '# Roadmap', + '', + '## v1.0.0', + '', + '### Phase 1: User Auth', + '**Goal:** Users can sign in.', + '**Mode:** mvp', + '', + ].join('\n'), + ); + const beforeFiles = snapshotProjectState(projectDir); + + const result = runGsdTools(['phase', 'mvp-mode', '1'], projectDir); + assert.equal(result.status, 0, result.stderr); + + const output = JSON.parse(result.stdout); + assert.equal(output.active, true); + assert.equal(output.source, 'roadmap'); + assert.equal(output.roadmap_mode, 'mvp'); + assert.equal(output.config_mvp_mode, false); + assert.equal(output.cli_flag_present, false); + assert.deepEqual(snapshotProjectState(projectDir), beforeFiles); + } finally { + cleanup(projectDir); + } + }); + + test('phase.mvp-mode ROADMAP lookup stops before custom-id next phase', () => { + const projectDir = createProject(); + try { + fs.writeFileSync( + path.join(projectDir, '.planning', 'ROADMAP.md'), + [ + '# Roadmap', + '', + '## v1.0.0', + '', + '### Phase 1: Numeric Phase', + '**Goal:** Users can sign in.', + '', + '### Phase custom-alpha: Custom Phase', + '**Goal:** Custom work.', + '**Mode:** mvp', + '', + ].join('\n'), + ); + const beforeFiles = snapshotProjectState(projectDir); + + const result = runGsdTools(['phase', 'mvp-mode', '1'], projectDir); + assert.equal(result.status, 0, result.stderr); + + const output = JSON.parse(result.stdout); + assert.equal(output.active, false); + assert.equal(output.source, 'none'); + assert.equal(output.roadmap_mode, null); + assert.deepEqual(snapshotProjectState(projectDir), beforeFiles); + } finally { + cleanup(projectDir); + } + }); + + test('phase.mvp-mode JSON error is typed and leaves project files untouched', () => { + const projectDir = createProject(); + try { + const beforeFiles = snapshotProjectState(projectDir); + const result = runGsdTools(['--json-errors', 'phase', 'mvp-mode'], projectDir); + assert.notEqual(result.status, 0); + assert.equal(result.stdout, ''); + + const error = JSON.parse(result.stderr); + assert.deepEqual(Object.keys(error).sort(), ['message', 'ok', 'reason']); + assert.equal(error.ok, false); + assert.equal(error.reason, 'usage'); + assert.equal(typeof error.message, 'string'); + assert.equal(/\n\s*at\s/.test(result.stderr), false, 'non-debug failure must not print a stack trace'); + assert.deepEqual(snapshotProjectState(projectDir), beforeFiles); + } finally { + cleanup(projectDir); + } + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/feat-488-effort-sync.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:feat-488-effort-sync (consolidation epic #1969 B3 #1972)", () => { +// Tests for gsd-tools effort sync command (#488) +// Verifies that effort frontmatter in installed agent files can be re-synced +// when effort config changes after initial install. +// allow-test-rule: structural-regression-guard — readFileSync asserts on installed agent .md files (the product under mutation) to verify dry-run safety and apply correctness; stderr.includes guards the CLI argument-rejection contract. (see #488) + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { spawnSync } = require('node:child_process'); + +const { cleanup } = require('./helpers.cjs'); + +const GSD_TOOLS = path.resolve(__dirname, '../gsd-core/bin/gsd-tools.cjs'); + +function runCli(args, env = {}) { + const result = spawnSync(process.execPath, [GSD_TOOLS, ...args], { + encoding: 'utf8', + env: { ...process.env, GSD_TEST_MODE: '1', ...env }, + }); + return result; +} + +function makeTmpDir(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +// output() in core.cjs uses fs.writeSync(1, data) — intercept fd=1 writes. +// Pass raw=false so output() emits JSON (raw=true emits the plain rawValue string). +function captureOutput(fn) { + const origWriteSync = fs.writeSync; + let captured = ''; + fs.writeSync = (fd, data) => { + if (fd === 1) captured += data; + else origWriteSync(fd, data); + }; + try { + fn(); + } finally { + fs.writeSync = origWriteSync; + } + return JSON.parse(captured); +} + +function makeAgentsDir(tmpDir) { + const agentsDir = path.join(tmpDir, 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + return agentsDir; +} + +function writePlanningConfig(tmpDir, effortConfig) { + const planningDir = path.join(tmpDir, '.planning'); + fs.mkdirSync(planningDir, { recursive: true }); + fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify({ effort: effortConfig })); +} + +const AGENT_WITH_EFFORT = `--- +name: gsd-planner +description: Plans phases for GSD milestones +effort: medium +--- +Body of the agent. +`; + +const AGENT_WITHOUT_EFFORT = `--- +name: gsd-executor +description: Executes GSD phase plans +--- +Body of the agent. +`; + +describe('feat-488: effort sync command', () => { + test('dry-run mode reports pending changes without writing files', () => { + const tmpDir = makeTmpDir('effort-sync-dry-'); + const agentsDir = makeAgentsDir(tmpDir); + const agentPath = path.join(agentsDir, 'gsd-planner.md'); + fs.writeFileSync(agentPath, AGENT_WITH_EFFORT); + writePlanningConfig(tmpDir, { default: 'high', agent_overrides: { 'gsd-planner': 'xhigh' } }); + + const { cmdEffortSync } = require('../gsd-core/bin/lib/commands.cjs'); + const result = captureOutput(() => + cmdEffortSync(tmpDir, false, { dryRun: true, configDir: tmpDir, runtime: 'claude' }) + ); + + assert.equal(result.dry_run, true); + assert.equal(result.synced, 1, 'should report 1 pending change'); + assert.equal(result.changes[0].agent, 'gsd-planner'); + assert.equal(result.changes[0].from, 'medium'); + assert.equal(result.changes[0].to, 'xhigh'); + + // dry-run must not modify the file + assert.ok(fs.readFileSync(agentPath, 'utf8').includes('effort: medium'), 'dry-run must not write file'); + + cleanup(tmpDir); + }); + + test('--apply mode rewrites effort: frontmatter to new config value', () => { + const tmpDir = makeTmpDir('effort-sync-apply-'); + const agentsDir = makeAgentsDir(tmpDir); + const agentPath = path.join(agentsDir, 'gsd-planner.md'); + fs.writeFileSync(agentPath, AGENT_WITH_EFFORT); + writePlanningConfig(tmpDir, { default: 'low', agent_overrides: { 'gsd-planner': 'xhigh' } }); + + const { cmdEffortSync } = require('../gsd-core/bin/lib/commands.cjs'); + const result = captureOutput(() => + cmdEffortSync(tmpDir, false, { dryRun: false, configDir: tmpDir, runtime: 'claude' }) + ); + + assert.equal(result.dry_run, false); + assert.equal(result.synced, 1); + + const updated = fs.readFileSync(agentPath, 'utf8'); + assert.ok(updated.includes('effort: xhigh'), 'file must be updated to xhigh'); + assert.ok(!updated.includes('effort: medium'), 'old effort value must be gone'); + + cleanup(tmpDir); + }); + + test('skips agents where effort: already matches config', () => { + const tmpDir = makeTmpDir('effort-sync-noop-'); + const agentsDir = makeAgentsDir(tmpDir); + const agentPath = path.join(agentsDir, 'gsd-planner.md'); + // Already has the correct value + fs.writeFileSync(agentPath, AGENT_WITH_EFFORT.replace('effort: medium', 'effort: xhigh')); + writePlanningConfig(tmpDir, { agent_overrides: { 'gsd-planner': 'xhigh' } }); + + const { cmdEffortSync } = require('../gsd-core/bin/lib/commands.cjs'); + const result = captureOutput(() => + cmdEffortSync(tmpDir, false, { dryRun: false, configDir: tmpDir, runtime: 'claude' }) + ); + + assert.equal(result.synced, 0, 'nothing to sync when already matching'); + assert.equal(result.skipped, 1); + + cleanup(tmpDir); + }); + + test('injects effort: into agent files that lack the frontmatter key', () => { + const tmpDir = makeTmpDir('effort-sync-inject-'); + const agentsDir = makeAgentsDir(tmpDir); + const agentPath = path.join(agentsDir, 'gsd-executor.md'); + fs.writeFileSync(agentPath, AGENT_WITHOUT_EFFORT); + writePlanningConfig(tmpDir, { default: 'max' }); + + const { cmdEffortSync } = require('../gsd-core/bin/lib/commands.cjs'); + const result = captureOutput(() => + cmdEffortSync(tmpDir, false, { dryRun: false, configDir: tmpDir, runtime: 'claude' }) + ); + + assert.equal(result.synced, 1, 'should inject effort into agent missing the key'); + assert.equal(result.changes[0].from, null); + assert.equal(result.changes[0].to, 'max'); + assert.ok(fs.readFileSync(agentPath, 'utf8').includes('effort: max'), 'effort must be injected'); + + cleanup(tmpDir); + }); + + test('non-claude runtime exits cleanly with informative reason field', () => { + const tmpDir = makeTmpDir('effort-sync-gemini-'); + + const { cmdEffortSync } = require('../gsd-core/bin/lib/commands.cjs'); + const result = captureOutput(() => + cmdEffortSync(tmpDir, false, { dryRun: true, runtime: 'gemini' }) + ); + + assert.ok(result.reason, 'should include a reason message for unsupported runtime'); + assert.equal(result.synced, 0); + + cleanup(tmpDir); + }); + + test('home-default effort config gap: applies home-level effort when project config has no effort section', () => { + // The key #488 scenario: user changed ~/.gsd/defaults.json effort settings + // after install, but the project .planning/config.json has no effort section. + // cmdEffortSync must pick up the home config (via readGsdEffectiveEffortConfig), + // not fall back to 'high' (which loadConfig would return). + // + // readGsdEffectiveEffortConfig calls os.homedir() directly, and os.homedir() + // is live (respects process.env.HOME). We redirect HOME to an isolated + // tmpHome so the test is hermetic and can assert the real outcome. + const tmpHome = makeTmpDir('effort-sync-homecfg-'); + const tmpDir = makeTmpDir('effort-sync-project-'); + const agentsDir = makeAgentsDir(tmpDir); + const agentPath = path.join(agentsDir, 'gsd-planner.md'); + fs.writeFileSync(agentPath, AGENT_WITH_EFFORT); // current: effort: medium + + // Project has .planning/config.json with NO effort section + const planningDir = path.join(tmpDir, '.planning'); + fs.mkdirSync(planningDir, { recursive: true }); + fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify({ model_profile: 'balanced' })); + + // Home defaults set effort.default = low + const gsdDir = path.join(tmpHome, '.gsd'); + fs.mkdirSync(gsdDir, { recursive: true }); + fs.writeFileSync(path.join(gsdDir, 'defaults.json'), JSON.stringify({ effort: { default: 'low' } })); + + // Isolate HOME (and USERPROFILE for Windows parity) so + // readGsdEffectiveEffortConfig reads our fixture, not the + // developer's real ~/.gsd/defaults.json. + const origHome = process.env.HOME; + const origUserProfile = process.env.USERPROFILE; + process.env.HOME = tmpHome; + process.env.USERPROFILE = tmpHome; + + const { cmdEffortSync } = require('../gsd-core/bin/lib/commands.cjs'); + let result; + try { + result = captureOutput(() => + cmdEffortSync(tmpDir, false, { dryRun: false, configDir: tmpDir, runtime: 'claude' }) + ); + } finally { + if (origHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = origHome; + } + if (origUserProfile === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = origUserProfile; + } + } + + // With home effort.default = 'low' and the agent currently at 'medium', + // cmdEffortSync must sync exactly 1 agent and set it to 'low'. + assert.equal(result.synced, 1, 'should sync 1 agent whose effort differs from home default'); + assert.equal(result.changes[0].agent, 'gsd-planner'); + assert.equal(result.changes[0].from, 'medium'); + assert.equal(result.changes[0].to, 'low', 'effort must be updated to the home-default value'); + assert.ok( + fs.readFileSync(agentPath, 'utf8').includes('effort: low'), + 'agent file must be rewritten with the home-default effort value' + ); + + cleanup(tmpHome); + cleanup(tmpDir); + }); + + test('CLI dispatcher: positional args after effort sync are rejected', () => { + const result = runCli(['effort', 'sync', 'unexpected-arg']); + assert.notEqual(result.status, 0, 'should exit non-zero on unexpected positional arg'); + assert.ok( + result.stderr.includes('positional') || result.stderr.includes('unexpected-arg'), + `stderr should mention the bad arg; got: ${result.stderr}` + ); + }); + + test('CLI dispatcher: effort sync --apply routes through gsd-tools correctly', () => { + const tmpDir = makeTmpDir('effort-sync-cli-'); + const agentsDir = makeAgentsDir(tmpDir); + const agentPath = path.join(agentsDir, 'gsd-planner.md'); + fs.writeFileSync(agentPath, AGENT_WITH_EFFORT); + writePlanningConfig(tmpDir, { agent_overrides: { 'gsd-planner': 'xhigh' } }); + + const result = runCli( + ['--cwd', tmpDir, 'effort', 'sync', '--apply', '--config-dir', tmpDir], + ); + + assert.equal(result.status, 0, `CLI exited non-zero: ${result.stderr}`); + // gsd-tools may print a startup banner before the JSON payload — parse from the first `{`. + const jsonStart = result.stdout.indexOf('{'); + const output = JSON.parse(result.stdout.slice(jsonStart)); + assert.equal(output.synced, 1); + assert.ok( + fs.readFileSync(agentPath, 'utf8').includes('effort: xhigh'), + 'CLI --apply must write the updated effort value' + ); + + cleanup(tmpDir); + }); +}); + }); +} diff --git a/tests/config-field-docs.test.cjs b/tests/config-field-docs.test.cjs index ad9fdc72a..99936372f 100644 --- a/tests/config-field-docs.test.cjs +++ b/tests/config-field-docs.test.cjs @@ -355,3 +355,131 @@ describe('CONFIGURATION.md parity (#1216)', () => { ); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-1494-workflow-config-key-docs.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-1494-workflow-config-key-docs (consolidation epic #1969 B3 #1972)", () => { +'use strict'; + +/** + * Parity assertions for #1494: workflow config keys that are consumed by + * planning-pipeline code must be (a) accepted by VALID_CONFIG_KEYS and + * (b) documented in references/planning-config.md. + * + * Per DEFECT.GENERATIVE-FIX: a shared constant / key-list that spans two + * surfaces requires a parity assertion that fails when the surfaces diverge. + */ + +const { describe, test, before, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs'); + +const CONFIG_SCHEMA_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'config-schema.cjs'); +const PLANNING_CONFIG_PATH = path.join(__dirname, '..', 'gsd-core', 'references', 'planning-config.md'); + +describe('VALID_CONFIG_KEYS parity — #1494 orphan-undocumented keys', () => { + const { VALID_CONFIG_KEYS } = require(CONFIG_SCHEMA_PATH); + + test('workflow.mvp_mode is in VALID_CONFIG_KEYS', () => { + assert.ok( + VALID_CONFIG_KEYS.has('workflow.mvp_mode'), + 'workflow.mvp_mode is read by config-loader.cts and plan-phase.md but was missing from VALID_CONFIG_KEYS (#1494)' + ); + }); + + test('workflow.code_review_command is in VALID_CONFIG_KEYS', () => { + assert.ok( + VALID_CONFIG_KEYS.has('workflow.code_review_command'), + 'workflow.code_review_command must be in VALID_CONFIG_KEYS' + ); + }); + + test('workflow.plan_chunked is in VALID_CONFIG_KEYS', () => { + assert.ok( + VALID_CONFIG_KEYS.has('workflow.plan_chunked'), + 'workflow.plan_chunked must be in VALID_CONFIG_KEYS' + ); + }); + + test('workflow.test_command is in VALID_CONFIG_KEYS', () => { + assert.ok( + VALID_CONFIG_KEYS.has('workflow.test_command'), + 'workflow.test_command must be in VALID_CONFIG_KEYS' + ); + }); + + test('workflow.build_command is in VALID_CONFIG_KEYS', () => { + assert.ok( + VALID_CONFIG_KEYS.has('workflow.build_command'), + 'workflow.build_command must be in VALID_CONFIG_KEYS' + ); + }); +}); + +describe('config-set accepts workflow.mvp_mode (#1494)', () => { + let tmpDir; + afterEach(() => { if (tmpDir) cleanup(tmpDir); }); + + test('config-set workflow.mvp_mode true succeeds and stores the value', () => { + tmpDir = createTempProject(); + const result = runGsdTools(['config-set', 'workflow.mvp_mode', 'true'], tmpDir); + assert.ok( + result.success, + `config-set workflow.mvp_mode must succeed; got:\nstdout: ${result.output}\nstderr: ${result.error}` + ); + const parsed = JSON.parse(result.output); + assert.strictEqual(parsed.updated, true, 'response must have updated:true'); + assert.strictEqual(parsed.key, 'workflow.mvp_mode', 'response must echo the key'); + }); + + test('config-set workflow.mvp_mode false succeeds', () => { + tmpDir = createTempProject(); + const result = runGsdTools(['config-set', 'workflow.mvp_mode', 'false'], tmpDir); + assert.ok( + result.success, + `config-set workflow.mvp_mode false must succeed; got:\nstdout: ${result.output}\nstderr: ${result.error}` + ); + const parsed = JSON.parse(result.output); + assert.strictEqual(parsed.updated, true); + }); +}); + +// allow-test-rule: source-text-is-the-product — planning-config.md is the deployed reference contract (#1494) +describe('planning-config.md documents #1494 keys', () => { + let content; + before(() => { content = fs.readFileSync(PLANNING_CONFIG_PATH, 'utf-8'); }); + + const KEYS = [ + 'workflow.mvp_mode', + 'workflow.code_review_command', + 'workflow.plan_chunked', + 'workflow.test_command', + 'workflow.build_command', + ]; + + for (const key of KEYS) { + test(`planning-config.md documents \`${key}\``, () => { + assert.ok( + content.includes(`\`${key}\``), + `planning-config.md must document \`${key}\` (#1494)` + ); + }); + + test(`\`${key}\` appears in the Complete Field Reference section`, () => { + const refSection = content.slice(content.indexOf('## Complete Field Reference')); + assert.ok( + refSection.includes(`\`${key}\``), + `planning-config.md Complete Field Reference must include \`${key}\` (#1494)` + ); + }); + } +}); + }); +} diff --git a/tests/config-get-default.test.cjs b/tests/config-get-default.test.cjs index 2f059b4db..baf525063 100644 --- a/tests/config-get-default.test.cjs +++ b/tests/config-get-default.test.cjs @@ -109,3 +109,270 @@ describe('config-get --default flag (#1893)', () => { assert.equal(parsed, 'json-test'); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2798-context-window-config-key.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2798-context-window-config-key (consolidation epic #1969 B3 #1972)", () => { +/** + * Regression test for bug #2798 + * + * `gsd-sdk query config-set context_window ` was rejected with + * "Unknown config key: context_window" because context_window was missing + * from VALID_CONFIG_KEYS in sdk/src/query/config-schema.ts. + * + * The fix added 'context_window' to the allowlist. + * This test prevents future drift where the key gets accidentally removed. + */ + +'use strict'; + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); +const { createTempProject, cleanup } = require('./helpers.cjs'); + +const REPO_ROOT = path.join(__dirname, '..'); +const SDK_CLI = path.join(REPO_ROOT, 'sdk', 'dist', 'cli.js'); + +function runConfigSet(key, value, projectDir) { + const argv = ['query', 'config-set', key, String(value), '--project-dir', projectDir]; + let stdout = ''; + let exitCode = 0; + try { + stdout = execFileSync(process.execPath, [SDK_CLI, ...argv], { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, GSD_SESSION_KEY: '' }, + }); + } catch (err) { + exitCode = err.status ?? 1; + stdout = err.stdout?.toString() ?? ''; + } + let json = null; + try { json = JSON.parse(stdout.trim()); } catch { /* ok */ } + return { exitCode, json }; +} + +describe('bug-2798: context_window is a valid config key', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject('gsd-test-2798-'); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ mode: 'balanced' }) + ); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('config-set context_window succeeds (not rejected as unknown key)', (t) => { + if (!fs.existsSync(SDK_CLI)) { + t.skip('sdk/dist/cli.js not built — run `cd sdk && npm run build` to enable this integration test'); + return; + } + const result = runConfigSet('context_window', 1000000, tmpDir); + + assert.strictEqual(result.exitCode, 0, 'should exit 0 (key is valid)'); + assert.ok(result.json !== null, 'should emit JSON'); + assert.strictEqual(result.json?.updated, true, 'updated should be true'); + assert.strictEqual(result.json?.key, 'context_window'); + }); + + test('context_window value is written to config.json', (t) => { + if (!fs.existsSync(SDK_CLI)) { + t.skip('sdk/dist/cli.js not built — run `cd sdk && npm run build` to enable this integration test'); + return; + } + runConfigSet('context_window', 500000, tmpDir); + + const config = JSON.parse( + fs.readFileSync(path.join(tmpDir, '.planning', 'config.json'), 'utf-8') + ); + assert.strictEqual(config.context_window, 500000, 'context_window should be persisted'); + }); + + test('config-schema CJS and SDK allowlists both include context_window', (t) => { + if (!fs.existsSync(path.join(REPO_ROOT, 'sdk', 'dist', 'query', 'config-schema.js'))) { + t.skip('sdk/dist/query/config-schema.js not built — run `cd sdk && npm run build` to enable this integration test'); + return; + } + const cjsSchema = require(path.join(REPO_ROOT, 'gsd-core', 'bin', 'lib', 'config-schema.cjs')); + const sdkSchema = require(path.join(REPO_ROOT, 'sdk', 'dist', 'query', 'config-schema.js')); + + assert.ok( + cjsSchema.VALID_CONFIG_KEYS.has('context_window'), + 'CJS VALID_CONFIG_KEYS must include context_window' + ); + assert.ok( + sdkSchema.VALID_CONFIG_KEYS.has('context_window'), + 'SDK VALID_CONFIG_KEYS must include context_window' + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2943-config-get-context-window-default.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2943-config-get-context-window-default (consolidation epic #1969 B3 #1972)", () => { +/** + * Regression test for bug #2943 + * + * `gsd-tools.cjs config-get context_window` (and the SDK equivalent) threw + * "Key not found: context_window" when the key was absent from config.json, + * even though context_window has a documented schema default of 200000. + * + * Fix: `cmdConfigGet` in bin/lib/config.cjs now consults a SCHEMA_DEFAULTS map + * before emitting "Key not found", so schema-defaulted keys always return the + * default value (exit 0) when not explicitly set in the project config. + */ + +'use strict'; + +// Migrated to typed-IR (#2974): the previous shape grepped stderr/stdout for +// "Key not found"; now the test passes `--json-errors` to gsd-tools and +// asserts on the structured `reason` code (a frozen-enum value from +// `core.cjs::ERROR_REASON`). Exit code is also a typed signal — together +// they fully discriminate the failure class. + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { execFileSync } = require('node:child_process'); + +const GSD_TOOLS = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs'); +const { ERROR_REASON } = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'io.cjs')); +const { cleanup } = require('./helpers.cjs'); + +describe('bug-2943: config-get returns schema default for context_window', () => { + let tmpDir; + let planningDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-2943-')); + planningDir = path.join(tmpDir, '.planning'); + fs.mkdirSync(planningDir, { recursive: true }); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + /** + * Run config-get with optional extra args. Returns { exitCode, stdout, stderr }. + * Uses --raw so we get the plain scalar value, not JSON-wrapped. + */ + function runConfigGet(keyPath, extraArgs = []) { + const args = [GSD_TOOLS, 'config-get', keyPath, '--raw', '--cwd', tmpDir, ...extraArgs]; + let stdout = ''; + let stderr = ''; + let exitCode = 0; + try { + // Windows/Node 22 under --test-concurrency=4 can starve subprocess slots when + // sharing a wave with bug-2760-codex-install (8–15s install subtests). 15s covers + // observed worst case (13.5s) with headroom. + stdout = execFileSync(process.execPath, args, { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 15000, + }); + } catch (err) { + exitCode = err.status ?? 1; + stdout = err.stdout?.toString() ?? ''; + stderr = err.stderr?.toString() ?? ''; + } + return { exitCode, stdout: stdout.trim(), stderr: stderr.trim() }; + } + + test('returns "200000" (exit 0) when context_window absent from config.json', () => { + // Fixture A: config with unrelated keys, no context_window + fs.writeFileSync( + path.join(planningDir, 'config.json'), + JSON.stringify({ workflow: { auto_advance: false } }) + ); + + const result = runConfigGet('context_window'); + + assert.strictEqual(result.exitCode, 0, 'should exit 0 (schema default applied)'); + assert.strictEqual(result.stdout, '200000', 'should return schema default of 200000'); + }); + + test('returns configured value when context_window is explicitly set', () => { + // Fixture B: config has context_window: 1000000 + fs.writeFileSync( + path.join(planningDir, 'config.json'), + JSON.stringify({ context_window: 1000000 }) + ); + + const result = runConfigGet('context_window'); + + assert.strictEqual(result.exitCode, 0, 'should exit 0 for found key'); + assert.strictEqual(result.stdout, '1000000', 'should return configured value not schema default'); + }); + + test('--default flag overrides schema default', () => { + // config has context_window but we pass --default with a different value — + // when key IS present, real value wins over any default + fs.writeFileSync( + path.join(planningDir, 'config.json'), + JSON.stringify({ workflow: { auto_advance: false } }) + ); + + const result = runConfigGet('context_window', ['--default', '123456']); + + assert.strictEqual(result.exitCode, 0, 'should exit 0 when --default provided'); + assert.strictEqual(result.stdout, '123456', 'should return the --default value, not schema default'); + }); + + test('errors with reason=CONFIG_KEY_NOT_FOUND (exit 1) for an unknown absent key — no regression', () => { + // An unrecognised key with no schema default still errors as before. + // Migrated #2974: assert on the structured reason code from --json-errors, + // not on substring presence in stderr/stdout text. + fs.writeFileSync( + path.join(planningDir, 'config.json'), + JSON.stringify({ workflow: { auto_advance: false } }) + ); + + const result = runConfigGet('totally_unknown_key_xyz', ['--json-errors']); + + assert.strictEqual(result.exitCode, 1, 'should exit 1 for unknown absent key'); + let parsed; + try { + parsed = JSON.parse(result.stderr); + } catch (err) { + assert.fail(`expected JSON-shaped stderr from --json-errors; got: ${JSON.stringify(result.stderr)}`); + } + assert.strictEqual(parsed.ok, false); + assert.strictEqual(parsed.reason, ERROR_REASON.CONFIG_KEY_NOT_FOUND, + `expected reason=${ERROR_REASON.CONFIG_KEY_NOT_FOUND}, got=${parsed.reason}`); + }); + + test('--default flag still works for arbitrary absent keys', () => { + fs.writeFileSync( + path.join(planningDir, 'config.json'), + JSON.stringify({}) + ); + + const result = runConfigGet('some.missing.key', ['--default', '200000']); + + assert.strictEqual(result.exitCode, 0, 'should exit 0 when --default supplied'); + assert.strictEqual(result.stdout, '200000', 'should return the explicit --default value'); + }); +}); + }); +} diff --git a/tests/config-loader.test.cjs b/tests/config-loader.test.cjs index a7d006f10..2cd95036c 100644 --- a/tests/config-loader.test.cjs +++ b/tests/config-loader.test.cjs @@ -518,3 +518,156 @@ describe('_deepMergeConfig — prototype-pollution guard (M4)', () => { assert.equal(typeof merged.constructor, 'function'); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2638-sub-repos-canonical-location.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2638-sub-repos-canonical-location (consolidation epic #1969 B3 #1972)", () => { +/** + * Regression test for bug #2638. + * + * loadConfig previously migrated/synced sub_repos to the TOP-LEVEL + * `parsed.sub_repos`, but the KNOWN_TOP_LEVEL allowlist only recognizes + * `planning.sub_repos` (per #2561 — canonical location). That asymmetry + * made loadConfig write a key it then warns is unknown on the next read. + * + * Fix: writers target `parsed.planning.sub_repos` and strip any stale + * top-level copy during the same migration pass. + */ + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const { execFileSync } = require('child_process'); +const { createTempProject, cleanup } = require('./helpers.cjs'); + +const { loadConfig } = require('../gsd-core/bin/lib/config-loader.cjs'); + +function makeSubRepo(parent, name) { + const dir = path.join(parent, name); + fs.mkdirSync(dir, { recursive: true }); + execFileSync('git', ['init'], { cwd: dir, stdio: 'pipe' }); +} + +function readConfig(tmpDir) { + return JSON.parse( + fs.readFileSync(path.join(tmpDir, '.planning', 'config.json'), 'utf-8') + ); +} + +function writeConfig(tmpDir, obj) { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify(obj, null, 2) + ); +} + +describe('bug #2638 — sub_repos canonical location', () => { + let tmpDir; + let originalCwd; + let stderrCapture; + let origStderrWrite; + + beforeEach(() => { + tmpDir = createTempProject(); + originalCwd = process.cwd(); + stderrCapture = ''; + origStderrWrite = process.stderr.write; + process.stderr.write = (chunk) => { stderrCapture += chunk; return true; }; + }); + + afterEach(() => { + process.stderr.write = origStderrWrite; + process.chdir(originalCwd); + cleanup(tmpDir); + }); + + test('does not warn when planning.sub_repos is set (no top-level sub_repos)', () => { + makeSubRepo(tmpDir, 'backend'); + makeSubRepo(tmpDir, 'frontend'); + writeConfig(tmpDir, { + planning: { sub_repos: ['backend', 'frontend'] }, + }); + + loadConfig(tmpDir); + + assert.ok( + !stderrCapture.includes('unknown config key'), + `should not warn for planning.sub_repos, got: ${stderrCapture}` + ); + assert.ok( + !stderrCapture.includes('sub_repos'), + `should not mention sub_repos at all, got: ${stderrCapture}` + ); + }); + + test('migrates legacy multiRepo:true into planning.sub_repos (not top-level)', () => { + makeSubRepo(tmpDir, 'backend'); + makeSubRepo(tmpDir, 'frontend'); + writeConfig(tmpDir, { multiRepo: true }); + + loadConfig(tmpDir); + + const after = readConfig(tmpDir); + assert.deepStrictEqual( + after.planning?.sub_repos, + ['backend', 'frontend'], + 'migration should write to planning.sub_repos' + ); + assert.strictEqual( + Object.prototype.hasOwnProperty.call(after, 'sub_repos'), + false, + 'migration must not leave a top-level sub_repos key' + ); + assert.strictEqual(after.multiRepo, undefined, 'legacy multiRepo should be removed'); + + assert.ok( + !stderrCapture.includes('unknown config key'), + `post-migration read should not warn, got: ${stderrCapture}` + ); + }); + + test('filesystem sync writes detected list to planning.sub_repos only', () => { + makeSubRepo(tmpDir, 'api'); + makeSubRepo(tmpDir, 'web'); + writeConfig(tmpDir, { planning: { sub_repos: ['api'] } }); + + loadConfig(tmpDir); + + const after = readConfig(tmpDir); + assert.deepStrictEqual(after.planning?.sub_repos, ['api', 'web']); + assert.strictEqual( + Object.prototype.hasOwnProperty.call(after, 'sub_repos'), + false, + 'sync must not create a top-level sub_repos key' + ); + assert.ok( + !stderrCapture.includes('unknown config key'), + `sync should not produce unknown-key warning, got: ${stderrCapture}` + ); + }); + + test('stale top-level sub_repos is stripped on load', () => { + makeSubRepo(tmpDir, 'backend'); + writeConfig(tmpDir, { + sub_repos: ['backend'], + planning: { sub_repos: ['backend'] }, + }); + + loadConfig(tmpDir); + + const after = readConfig(tmpDir); + assert.strictEqual( + Object.prototype.hasOwnProperty.call(after, 'sub_repos'), + false, + 'stale top-level sub_repos should be removed to self-heal legacy installs' + ); + assert.deepStrictEqual(after.planning?.sub_repos, ['backend']); + }); +}); + }); +} diff --git a/tests/config-schema.property.test.cjs b/tests/config-schema.property.test.cjs index 2b2e9c976..0302f211f 100644 --- a/tests/config-schema.property.test.cjs +++ b/tests/config-schema.property.test.cjs @@ -266,3 +266,830 @@ describe('capability trust config keys (ADR-1244 Phase 4)', () => { assert.equal(CONFIG_DEFAULTS.capabilities.auto_update, false); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2986-config-schema-mutation-killers.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2986-config-schema-mutation-killers (consolidation epic #1969 B3 #1972)", () => { +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * Bug #2986: Layer-3 fault-detection audit found 4.62% Stryker mutation + * score on gsd-core/bin/lib/config-schema.cjs (6 killed, 124 survived). + * Surviving mutants document tests that "exercise paths" but don't + * "verify outputs" -- a polarity flip or predicate swap inside the lib + * passed every existing test. + * + * Sample surviving mutants from #2986: + * M1: `if (VALID_CONFIG_KEYS.has(keyPath)) return true;` + * -> `if (false) return true;` + * Killer: a test that asserts isValidConfigKey returns true for + * every member of VALID_CONFIG_KEYS. If VALID_CONFIG_KEYS.has is + * short-circuited to false, those keys would only be accepted if + * a DYNAMIC_KEY_PATTERN matches them -- and none of the static + * keys match any dynamic pattern by design. + * + * M2: `return DYNAMIC_KEY_PATTERNS.some((p) => p.test(keyPath));` + * -> `return DYNAMIC_KEY_PATTERNS.every(p => p.test(keyPath));` + * Killer: a test that supplies a key matching ONE pattern but not + * every pattern. With `.every`, that key is rejected; with `.some`, + * accepted. The current dynamic-pattern set is mutually exclusive + * (e.g., `agent_skills.foo` matches the agent_skills regex but not + * review/features/claude_md_assembly/model_profile_overrides), so + * any single dynamic-key sample suffices. + * + * M3: `return true` -> `return false` on the early-return line + * Killer: a test that uses a known-valid static key and asserts + * the boolean true (not just "non-falsy" or "no throw"). A + * polarity flip turns the true into false; the assertion catches it. + * + * M4: `if (VALID_CONFIG_KEYS.has(keyPath)) return true;` -> remove the + * guard entirely (return DYNAMIC_KEY_PATTERNS.some(...) always). + * Killer: same as M1 -- static keys that don't match any dynamic + * pattern would be wrongly rejected. + * + * These tests exercise the lib's PUBLIC SURFACE (isValidConfigKey) + * with structured inputs and assert on typed boolean outputs. No regex + * on source code; no source-grep. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const { + VALID_CONFIG_KEYS, + DYNAMIC_KEY_PATTERNS, + isValidConfigKey, +} = require('../gsd-core/bin/lib/config-schema.cjs'); + +describe('Bug #2986: M1/M4 -- isValidConfigKey returns true for EVERY static key in VALID_CONFIG_KEYS', () => { + // Stryker mutants like `if (false) return true;` would silently flip + // every static key to "rejected" because none of the static keys match + // any dynamic pattern by design. This parameterized test is the + // mutation-kill equivalent for that branch. + for (const key of VALID_CONFIG_KEYS) { + test(`isValidConfigKey('${key}') === true`, () => { + assert.strictEqual(isValidConfigKey(key), true, + `static config key '${key}' must be accepted (catches Stryker mutant on the static-key fast path)`); + }); + } +}); + +describe('Bug #2986: M2 -- DYNAMIC_KEY_PATTERNS.some semantic, not .every', () => { + // Each pattern has a representative key that matches ONLY that pattern + // (mutually exclusive with the others by design) AND is NOT a member of + // VALID_CONFIG_KEYS. The static-key fast-path returns true before + // DYNAMIC_KEY_PATTERNS.some() ever runs, so any rep key that's also in + // VALID_CONFIG_KEYS gives the M2 killer zero coverage for that pattern + // (#3005 CR: this caught features.thinking_partner, which IS in static). + // A reserved-prefix-style placeholder name is used for `features` so the + // dynamic path is the only way to reach `true`. + const patternRepresentatives = [ + { key: 'agent_skills.gsd-planner', topLevel: 'agent_skills' }, + { key: 'review.models.claude', topLevel: 'review' }, + { key: 'features.some_dynamic_feature', topLevel: 'features' }, + { key: 'claude_md_assembly.blocks.intro', topLevel: 'claude_md_assembly' }, + { key: 'model_profile_overrides.codex.opus', topLevel: 'model_profile_overrides' }, + ]; + + for (const { key, topLevel } of patternRepresentatives) { + test(`isValidConfigKey('${key}') === true (matches '${topLevel}' pattern via dynamic path)`, () => { + // Invariant: the rep key MUST NOT be in the static set. Otherwise the + // static fast-path short-circuits and the dynamic-pattern .some() is + // never invoked, so a mutation removing this entry from + // DYNAMIC_KEY_PATTERNS would survive. + assert.strictEqual(VALID_CONFIG_KEYS.has(key), false, + `representative key '${key}' must NOT be in VALID_CONFIG_KEYS — otherwise the static fast-path masks the dynamic-pattern test (#3005 CR)`); + assert.strictEqual(isValidConfigKey(key), true, + `dynamic key '${key}' must be accepted via DYNAMIC_KEY_PATTERNS.some`); + // Verify mutual exclusivity: only one pattern matches this key. + const matchCount = DYNAMIC_KEY_PATTERNS.filter((p) => p.test(key)).length; + assert.strictEqual(matchCount, 1, + `mutual-exclusivity invariant: '${key}' must match exactly 1 pattern, matched ${matchCount}. ` + + `If this fails, dynamic-pattern overlap was introduced and the .some-vs-.every mutation killer breaks.`); + }); + } +}); + +describe('Bug #2986: M3 -- polarity assertion (true is true, not just truthy)', () => { + // Stryker mutants that flip `return true` to `return false` are killed + // by strictEqual against the boolean true. assert.ok would tolerate any + // truthy value (e.g., a non-empty string returned by a different mutation). + test('isValidConfigKey returns the literal boolean true for static keys', () => { + const result = isValidConfigKey('model_profile'); + assert.strictEqual(result, true); + assert.strictEqual(typeof result, 'boolean'); + }); + + test('isValidConfigKey returns the literal boolean false for unknown keys', () => { + const result = isValidConfigKey('totally_unknown_key_xyz'); + assert.strictEqual(result, false); + assert.strictEqual(typeof result, 'boolean'); + }); + + test('isValidConfigKey returns false for a dynamic-pattern-shape key under a non-existent topLevel', () => { + // E.g., `unrelated.models.claude` syntactically resembles a dynamic + // pattern but no DYNAMIC_KEY_PATTERN owns the `unrelated` topLevel. + // A mutant that loosens the regex anchors would falsely accept this. + assert.strictEqual(isValidConfigKey('unrelated.models.claude'), false); + }); +}); + +describe('Bug #2986: anchor-tightening (catches mutants that loosen ^ or $ in regexes)', () => { + // Each dynamic regex is anchored. Mutants that drop ^ or $ would + // accept too much. These keys differ from a valid one by ONE character + // beyond the documented shape; they must be rejected. + const overshoot = [ + { key: 'agent_skills.gsd-planner.extra', reason: 'agent_skills regex must not allow trailing dot-segment' }, + { key: 'agent_skills.', reason: 'agent_skills regex requires non-empty agent name' }, + { key: 'review.models.', reason: 'review.models regex requires non-empty cli name' }, + { key: 'features.bad name with spaces', reason: 'features regex disallows spaces' }, + { key: 'model_profile_overrides.codex.gpt5', reason: 'model_profile_overrides tier is enum-restricted to opus|sonnet|haiku' }, + { key: 'model_profile_overrides.codex', reason: 'model_profile_overrides requires . suffix' }, + ]; + + for (const { key, reason } of overshoot) { + test(`isValidConfigKey('${key}') === false -- ${reason}`, () => { + assert.strictEqual(isValidConfigKey(key), false, + `'${key}' must be rejected (catches anchor/charset-loosening mutants)`); + }); + } +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/feat-2527-settings-layers.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:feat-2527-settings-layers (consolidation epic #1969 B3 #1972)", () => { +'use strict'; + +/** + * Feature test for #2527 — /gsd-settings expands to 22 settings grouped into + * six visual sections. Adds 8 new fields (pattern_mapper, tdd_mode, code_review, + * code_review_depth, ui_review, commit_docs, intel.enabled, graphify.enabled) + * and verifies each is present in the AskUserQuestion block, the update_config + * step, the confirmation table, the ~/.gsd/defaults.json save step, and the + * effective config-key validator. + * + * Closes: #2527 + */ + +const { describe, test, before } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); + +const SETTINGS_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'settings.md'); +const { + VALID_CONFIG_KEYS, + isCentralConfigKey, + isValidConfigKey, +} = require('../gsd-core/bin/lib/config-schema.cjs'); + +const NEW_FIELDS = [ + 'workflow.pattern_mapper', + 'workflow.tdd_mode', + 'workflow.code_review', + 'workflow.code_review_depth', + 'workflow.ui_review', + 'commit_docs', + 'intel.enabled', + 'graphify.enabled', +]; + +const CENTRAL_NEW_FIELDS = [ + 'commit_docs', +]; + +const CAPABILITY_OWNED_NEW_FIELDS = NEW_FIELDS.filter((field) => !CENTRAL_NEW_FIELDS.includes(field)); + +const SECTION_HEADERS = ['Planning', 'Execution', 'Docs & Output', 'Features', 'Model & Pipeline', 'Misc']; + +/** + * Match a dotted config-key path inside a block of text. Falls back to a + * simple substring check for single-segment keys; for nested keys, requires + * each segment to appear in order within a bounded window so distinct fields + * (e.g., intel.enabled vs graphify.enabled) cannot collapse to the same leaf. + */ +function hasPathLike(block, field) { + const parts = field.split('.'); + if (parts.length === 1) return block.includes(parts[0]); + const escaped = parts.map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); + const pattern = new RegExp(escaped.join('[\\s\\S]{0,600}'), 'i'); + return pattern.test(block); +} + +describe('#2527: settings.md adds grouped settings layers', () => { + let content; + + before(() => { + content = fs.readFileSync(SETTINGS_PATH, 'utf-8'); + }); + + describe('Acceptance: all 8 new fields present in AskUserQuestion block', () => { + for (const field of NEW_FIELDS) { + test(`settings.md mentions ${field}`, () => { + assert.ok( + content.includes(field), + `settings.md must reference the config key "${field}" in its AskUserQuestion/update_config step` + ); + }); + } + }); + + describe('Acceptance: section headers applied', () => { + for (const section of SECTION_HEADERS) { + test(`settings.md declares a "${section}" section header`, () => { + // The convention for grouping AskUserQuestion items is a markdown section heading + // of the form "###
" inside the present_settings step. + const heading = new RegExp(`^#{2,4}\\s+${section.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}\\b`, 'm'); + assert.ok( + heading.test(content), + `settings.md must declare a "${section}" section header to group questions` + ); + }); + } + }); + + describe('Acceptance: update_config step includes all new fields', () => { + test('update_config step references every new field', () => { + const updateMatch = content.match(/[\s\S]*?<\/step>/); + assert.ok(updateMatch, 'settings.md must have an update_config step'); + const updateBlock = updateMatch[0]; + for (const field of NEW_FIELDS) { + // Keys may appear as nested JSON (e.g., "pattern_mapper" under workflow). + // Use hasPathLike so distinct dotted keys (e.g., intel.enabled, + // graphify.enabled) cannot share a single "enabled" occurrence. + assert.ok( + hasPathLike(updateBlock, field), + `update_config step must write "${field}"` + ); + } + }); + }); + + describe('Acceptance: save_as_defaults step includes all new fields', () => { + test('save_as_defaults step references every new field', () => { + const defaultsMatch = content.match(/[\s\S]*?<\/step>/); + assert.ok(defaultsMatch, 'settings.md must have a save_as_defaults step'); + const block = defaultsMatch[0]; + for (const field of NEW_FIELDS) { + assert.ok( + hasPathLike(block, field), + `save_as_defaults step must persist "${field}" into ~/.gsd/defaults.json` + ); + } + }); + }); + + describe('Acceptance: confirmation display includes all new fields', () => { + test('confirm step table lists every new setting by name', () => { + const confirmMatch = content.match(/[\s\S]*?<\/step>/); + assert.ok(confirmMatch, 'settings.md must have a confirm step'); + const block = confirmMatch[0]; + const expectedLabels = [ + 'Pattern Mapper', + 'TDD Mode', + 'Code Review', + 'Code Review Depth', + 'UI Review', + 'Commit Docs', + 'Intel', + 'Graphify', + ]; + for (const label of expectedLabels) { + assert.ok( + block.includes(label), + `confirm step table must display "${label}"` + ); + } + }); + }); + + describe('Acceptance: all 8 new fields accepted by the config validator', () => { + for (const field of NEW_FIELDS) { + test(`config validator accepts ${field}`, () => { + assert.ok( + isValidConfigKey(field), + `${field} must be accepted so config-set can write it` + ); + }); + } + }); + + describe('Acceptance: migrated capability fields are no longer central config keys', () => { + for (const field of CAPABILITY_OWNED_NEW_FIELDS) { + test(`${field} is capability-owned, not central-schema residue`, () => { + assert.equal( + isCentralConfigKey(field), + false, + `${field} must be owned by the capability registry instead of the central schema` + ); + assert.equal( + VALID_CONFIG_KEYS.has(field), + false, + `${field} must not be duplicated in VALID_CONFIG_KEYS after Phase 6 migration` + ); + }); + } + }); + + describe('Acceptance: still-central settings remain in VALID_CONFIG_KEYS', () => { + for (const field of CENTRAL_NEW_FIELDS) { + test(`VALID_CONFIG_KEYS contains central setting ${field}`, () => { + assert.ok( + VALID_CONFIG_KEYS.has(field), + `${field} is not a migrated capability key and must remain in VALID_CONFIG_KEYS` + ); + }); + } + }); + + describe('Acceptance: code_review_depth is conditional on code_review=on', () => { + test('settings.md documents conditional visibility for code_review_depth', () => { + // Must explicitly note that code_review_depth only appears when code_review is on. + const conditionalRegex = /code_review_depth[\s\S]{0,400}(only|conditional|when|if)[\s\S]{0,80}code_review/i; + assert.ok( + conditionalRegex.test(content) || + /code_review\s*=\s*on[\s\S]{0,400}code_[…]*depth/i.test(content), + 'settings.md must document that code_review_depth is only shown when code_review is on' + ); + }); + }); + + describe('Negative: settings.md constrains code_review_depth options', () => { + test('settings.md restricts code_review_depth to a known option set', () => { + // Depth accepts string values (quick|standard|deep). config-set does not + // block arbitrary strings at the value level today; instead settings.md + // constrains the AskUserQuestion options to the valid set so users + // cannot pick "bogus" via the interactive flow. + const depthOptionsRegex = + /code_review_depth[\s\S]{0,800}(quick|standard|deep|surface)/i; + assert.ok( + depthOptionsRegex.test(content), + 'settings.md must constrain code_review_depth options to a known set' + ); + }); + }); + + describe('Negative: config-set rejects an unknown key path', () => { + test('config-set workflow.code_review_bogus_key fails', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + + const bad = runGsdTools(['config-set', 'workflow.code_review_bogus_key', 'x'], tmpDir); + assert.ok(!bad.success, 'config-set on an unknown key must fail'); + }); + }); + + describe('Acceptance: all 6 section headers are used as header: field on first question in each section', () => { + test('the header field appears for each section in the AskUserQuestion block', () => { + // Map user-visible section names to the short `header:` strings used in AskUserQuestion. + // settings.md uses abbreviated headers (max 12 chars). Verify at least one header + // per section-intent appears on a question. + const requiredHeaders = [ + /header:\s*"Model"/, // Model & Pipeline opener + /header:\s*"Research"/, // Planning opener (first Planning-section question) + /header:\s*"Pattern Mapper"|header:\s*"Patterns"/, // new Planning addition + /header:\s*"Verifier"/, // Execution existing + /header:\s*"TDD"/, // new Execution + /header:\s*"Code Review"/, // new Execution + /header:\s*"UI Review"/, // new Execution + /header:\s*"Commit Docs"/, // new Docs & Output + /header:\s*"Intel"/, // new Features + /header:\s*"Graphify"/, // new Features + ]; + for (const re of requiredHeaders) { + assert.ok(re.test(content), `settings.md must include an AskUserQuestion header matching ${re}`); + } + }); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/feat-3210-fallow-integration.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:feat-3210-fallow-integration (consolidation epic #1969 B3 #1972)", () => { +// allow-test-rule: source-text-is-the-product (see #3210) +// This test validates workflow/agent/config contracts stored in shipped .md/.ts/.cjs +// artifacts. Source text is the runtime product for those surfaces. + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs'); + +const ROOT = path.resolve(__dirname, '..'); + +// N2: single helper — on macOS os.tmpdir() already returns /private/tmp; the +// existsSync guard is kept only as defense-in-depth fallback. +function getWritableTmp() { + const candidates = ['/private/tmp', '/tmp', os.tmpdir()]; + return candidates.find((dir) => { + try { + fs.accessSync(dir, fs.constants.W_OK); + return true; + } catch { + return false; + } + }); +} + +describe('feat-3210: fallow integration module', () => { + test('normalizes structural findings from a fallow report', () => { + const { normalizeFallowReport } = require('../gsd-core/bin/lib/fallow-runner.cjs'); + const fixture = JSON.parse( + fs.readFileSync(path.join(ROOT, 'tests', 'fixtures', 'fallow', 'sample-findings.json'), 'utf8'), + ); + + const normalized = normalizeFallowReport(fixture); + // Counts derived from real schema fixture fields + const expectedUnused = fixture.dead_code.unused_exports.length; + const expectedUnusedFiles = fixture.dead_code.unused_files.length; + const expectedCircular = fixture.dead_code.circular_dependencies.length; + const expectedDuplicates = fixture.duplication.clone_groups.length; + assert.deepStrictEqual(normalized.summary, { + unused_exports: expectedUnused, + unused_files: expectedUnusedFiles, + duplicates: expectedDuplicates, + circular_dependencies: expectedCircular, + total: 4, + }); + assert.strictEqual(normalized.findings.length, 4); + }); + + test('falls back to node_modules/.bin/fallow when PATH does not contain fallow', () => { + const { resolveFallowBinary } = require('../gsd-core/bin/lib/fallow-runner.cjs'); + // N2: use shared helper + const baseTmp = getWritableTmp(); + const tmp = fs.mkdtempSync(path.join(baseTmp, 'gsd-fallow-bin-')); + const binDir = path.join(tmp, 'node_modules', '.bin'); + fs.mkdirSync(binDir, { recursive: true }); + const fallowPath = path.join(binDir, 'fallow'); + fs.writeFileSync(fallowPath, '#!/usr/bin/env sh\n'); + if (process.platform !== 'win32') fs.chmodSync(fallowPath, 0o755); + + const resolved = resolveFallowBinary({ cwd: tmp, envPath: '' }); + assert.strictEqual(resolved, fallowPath); + + cleanup(tmp); + }); + + // H6: replaced wholesale win32 skip with platform-adapted assertion + test('ignores non-executable PATH candidate on non-Windows; prefers .cmd over bare extensionless on Windows', () => { + const { resolveFallowBinary } = require('../gsd-core/bin/lib/fallow-runner.cjs'); + // N2: use shared helper + const baseTmp = getWritableTmp(); + + if (process.platform === 'win32') { + // H6: Windows — .cmd extension candidate must be preferred over bare extensionless file + const tmp = fs.mkdtempSync(path.join(baseTmp, 'gsd-fallow-win-')); + try { + const pathDir = path.join(tmp, 'bin'); + fs.mkdirSync(pathDir, { recursive: true }); + const bareFile = path.join(pathDir, 'fallow'); + const cmdFile = path.join(pathDir, 'fallow.cmd'); + fs.writeFileSync(bareFile, '@echo off\r\n'); + fs.writeFileSync(cmdFile, '@echo off\r\n'); + const resolved = resolveFallowBinary({ cwd: tmp, envPath: pathDir }); + assert.strictEqual( + resolved, + cmdFile, + 'Windows: .cmd candidate must be preferred over bare extensionless file', + ); + } finally { + cleanup(tmp); + } + } else { + // H6: non-Windows — non-executable file in PATH must be ignored + const tmp = fs.mkdtempSync(path.join(baseTmp, 'gsd-fallow-nonexec-')); + try { + const pathDir = path.join(tmp, 'bin'); + fs.mkdirSync(pathDir, { recursive: true }); + const nonExec = path.join(pathDir, 'fallow'); + fs.writeFileSync(nonExec, '#!/usr/bin/env sh\n'); + fs.chmodSync(nonExec, 0o644); + const resolved = resolveFallowBinary({ cwd: tmp, envPath: pathDir }); + assert.strictEqual(resolved, null); + } finally { + cleanup(tmp); + } + } + }); + + test('normalizes empty fallow report to zero findings', () => { + const { normalizeFallowReport } = require('../gsd-core/bin/lib/fallow-runner.cjs'); + const fixture = JSON.parse( + fs.readFileSync(path.join(ROOT, 'tests', 'fixtures', 'fallow', 'sample-empty.json'), 'utf8'), + ); + const normalized = normalizeFallowReport(fixture); + assert.deepStrictEqual(normalized.summary, { + unused_exports: 0, + unused_files: 0, + duplicates: 0, + circular_dependencies: 0, + total: 0, + }); + assert.deepStrictEqual(normalized.findings, []); + }); + + test('throws actionable error when fallow is enabled but binary is unavailable', () => { + const { requireFallowBinary } = require('../gsd-core/bin/lib/fallow-runner.cjs'); + // N2: use shared helper + const baseTmp = getWritableTmp(); + const tmp = fs.mkdtempSync(path.join(baseTmp, 'gsd-fallow-missing-')); + assert.throws( + () => requireFallowBinary({ cwd: tmp, envPath: '' }), + /install fallow via `npm install -D fallow` or `cargo install fallow`/, + ); + cleanup(tmp); + }); + + // M5: edge-case fixture — line:0 preservation, unicode path, single-instance clone_group, 3-file cycle + test('normalizes edge-case fixture: line:0 preservation, unicode path, single-instance clone_group, 3-file cycle', () => { + const { normalizeFallowReport } = require('../gsd-core/bin/lib/fallow-runner.cjs'); + const fixture = JSON.parse( + fs.readFileSync( + path.join(ROOT, 'tests', 'fixtures', 'fallow', 'sample-edge-cases.json'), + 'utf8', + ), + ); + + // Real schema: unused_export with line:0 — must survive without coercion + assert.strictEqual(fixture.dead_code.unused_exports.length, 1); + assert.strictEqual(fixture.dead_code.unused_exports[0].line, 0, 'edge-case fixture: line must be 0'); + // unicode file path is preserved in fixture + assert.ok( + fixture.dead_code.unused_exports[0].path.includes('café'), + 'edge-case fixture: unicode file path must be present', + ); + + // single-instance clone_group (related_file normalizes to '') + assert.strictEqual(fixture.duplication.clone_groups.length, 1); + assert.strictEqual(fixture.duplication.clone_groups[0].instances.length, 1); + + // 3-file circular dependency cycle + assert.strictEqual(fixture.dead_code.circular_dependencies.length, 1); + assert.strictEqual( + fixture.dead_code.circular_dependencies[0].files.length, + 3, + 'edge-case: files array must have exactly 3 entries', + ); + + // normalization round-trips without throwing + const normalized = normalizeFallowReport(fixture); + // 1 unused_export + 0 unused_files + 1 circular_dep + 1 clone_group = 3 + const expectedTotal = + fixture.dead_code.unused_exports.length + + fixture.dead_code.unused_files.length + + fixture.dead_code.circular_dependencies.length + + fixture.duplication.clone_groups.length; + assert.strictEqual(normalized.findings.length, expectedTotal); + assert.strictEqual(normalized.summary.total, expectedTotal); + + // line:0 survives normalization + const unicodeFinding = normalized.findings.find( + (f) => typeof f.file === 'string' && f.file.includes('café'), + ); + assert.ok(unicodeFinding, 'unicode file path must survive normalization round-trip'); + assert.strictEqual(unicodeFinding.line, 0, 'line:0 must not be coerced to null'); + + // single-instance clone_group: related_file must be '' + const dupFinding = normalized.findings.find((f) => f.type === 'duplicate_block'); + assert.ok(dupFinding, 'duplicate_block finding must exist'); + assert.strictEqual(dupFinding.related_file, '', 'single-instance clone_group: related_file must be empty string'); + }); +}); + +describe('feat-3210: H1 - line:0 preservation', () => { + test('normalizeFallowReport preserves line:0 for unused_export (not coerced to null)', () => { + const { normalizeFallowReport } = require('../gsd-core/bin/lib/fallow-runner.cjs'); + const report = { + dead_code: { + unused_exports: [{ path: 'src/a.ts', export_name: 'foo', line: 0 }], + }, + }; + const normalized = normalizeFallowReport(report); + assert.strictEqual(normalized.findings[0].line, 0, 'line:0 must not be coerced to null via ||'); + }); + + test('normalizeFallowReport preserves line:0 for duplicate_block instances[0].start_line (not coerced to null)', () => { + const { normalizeFallowReport } = require('../gsd-core/bin/lib/fallow-runner.cjs'); + const report = { + duplication: { + clone_groups: [ + { instances: [{ file: 'src/a.ts', start_line: 0 }, { file: 'src/b.ts', start_line: 5 }] }, + ], + }, + }; + const normalized = normalizeFallowReport(report); + assert.strictEqual(normalized.findings[0].line, 0, 'start_line:0 must not be coerced to null via ||'); + }); +}); + +describe('feat-3210: M2 - node_modules/.bin resolution order', () => { + test('resolveFallowBinary prefers node_modules/.bin over PATH when both exist', () => { + const { resolveFallowBinary } = require('../gsd-core/bin/lib/fallow-runner.cjs'); + // N2: use shared helper + const baseTmp = getWritableTmp(); + const tmp = fs.mkdtempSync(path.join(baseTmp, 'gsd-fallow-order-')); + try { + // local node_modules/.bin/fallow + const binDir = path.join(tmp, 'node_modules', '.bin'); + fs.mkdirSync(binDir, { recursive: true }); + const localFallow = path.join(binDir, 'fallow'); + fs.writeFileSync(localFallow, '#!/usr/bin/env sh\necho local\n'); + if (process.platform !== 'win32') fs.chmodSync(localFallow, 0o755); + + // PATH fallow (a different file) + const pathDir = path.join(tmp, 'pathbin'); + fs.mkdirSync(pathDir, { recursive: true }); + const pathFallow = path.join(pathDir, 'fallow'); + fs.writeFileSync(pathFallow, '#!/usr/bin/env sh\necho path\n'); + if (process.platform !== 'win32') fs.chmodSync(pathFallow, 0o755); + + const resolved = resolveFallowBinary({ cwd: tmp, envPath: pathDir }); + assert.strictEqual(resolved, localFallow, 'node_modules/.bin/fallow must win over PATH fallow'); + } finally { + cleanup(tmp); + } + }); +}); + +describe('feat-3210 / #1012: code-review workflow invokes fallow with the real CLI', () => { + // allow-test-rule: source-text-is-the-product — code-review.md IS the workflow the orchestrator (see #3210) + // executes; its fallow invocation is the product surface. + const workflowSrc = fs.readFileSync( + path.join(ROOT, 'gsd-core', 'workflows', 'code-review.md'), + 'utf8', + ); + + test('uses audit --format json and --quiet (real fallow 2.x flags)', () => { + assert.ok( + workflowSrc.includes('audit --format json'), + 'workflow must invoke: audit --format json', + ); + assert.ok( + workflowSrc.includes('--quiet'), + 'workflow must pass --quiet to suppress progress output', + ); + }); + + test('does NOT use removed flags: --json , --profile, --stdin-files', () => { + assert.ok( + !workflowSrc.includes('--json '), + 'workflow must not use old --json flag (note trailing space to avoid matching --format json)', + ); + assert.ok( + !workflowSrc.includes('--profile'), + 'workflow must not use --profile (fallow has no native profile concept)', + ); + assert.ok( + !workflowSrc.includes('--stdin-files'), + 'workflow must not use --stdin-files (removed in fallow 2.x)', + ); + }); + + test('uses --max-crap for threshold control (profile maps to max-crap)', () => { + assert.ok( + workflowSrc.includes('--max-crap'), + 'workflow must use --max-crap to control threshold (profile mapped to this flag)', + ); + }); + + test('scopes phase via --changed-since (native fallow git-ref scoping)', () => { + assert.ok( + workflowSrc.includes('--changed-since'), + 'workflow must use --changed-since for phase scoping', + ); + }); + + test('normalizes fallow output via normalizeFallowReportFile before embedding', () => { + assert.ok( + workflowSrc.includes('normalizeFallowReportFile'), + 'workflow must call normalizeFallowReportFile to normalize before embedding into reviewer prompt', + ); + }); + + test('exit-handling gates on valid JSON (verdict in o), not on exit code', () => { + assert.ok( + workflowSrc.includes("'verdict' in o"), + "workflow exit-handling must use 'verdict' in o to decide success (not exit code)", + ); + }); +}); + +describe('feat-3210: workflow and config contracts', () => { + test('config schema allows code_quality.fallow.* keys in CJS and runtime manifest', () => { + // CJS config-schema and runtime consume the same manifest source-of-truth. + // Use the CJS runtime Set and the manifest directly (no inline text parsing). + const { VALID_CONFIG_KEYS } = require('../gsd-core/bin/lib/config-schema.cjs'); + const manifestPath = path.join(ROOT, 'gsd-core', 'bin', 'shared', 'config-schema.manifest.json'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const manifestKeys = new Set(manifest.validKeys); + for (const key of [ + 'code_quality.fallow.enabled', + 'code_quality.fallow.scope', + 'code_quality.fallow.profile', + 'code_quality.fallow.mcp', + ]) { + assert.ok(VALID_CONFIG_KEYS.has(key), `missing CJS config key: ${key}`); + assert.ok(manifestKeys.has(key), `missing manifest key: ${key} (runtime sources from manifest)`); + } + }); + + test('config-set accepts code_quality.fallow keys', () => { + const originalTmpDir = process.env.TMPDIR; + // L2: fail loudly if no writable tmp dir is found (was silent skip) + const writableTmp = getWritableTmp(); // N2: use shared helper + assert.ok(writableTmp, 'no writable tmp directory found'); // L2: explicit fail-loud assertion + process.env.TMPDIR = writableTmp; + const tmpDir = createTempProject('gsd-fallow-config-'); + try { + const cases = [ + ['code_quality.fallow.enabled', 'true'], + ['code_quality.fallow.scope', 'repo'], + ['code_quality.fallow.profile', 'strict'], + ['code_quality.fallow.mcp', 'false'], + ]; + for (const [key, value] of cases) { + const result = runGsdTools(['config-set', key, value], tmpDir); + assert.ok(result.success, `config-set failed for ${key}: ${result.error || result.output}`); + } + } finally { + cleanup(tmpDir); + if (originalTmpDir === undefined) delete process.env.TMPDIR; + else process.env.TMPDIR = originalTmpDir; + } + }); + + // B4: replaced 5x source-grep tautologies with parse-based structural checks. + // The workflow .md uses XML-like tags as its runtime DSL; we parse the step block + // structurally and assert on structural properties, not on prose strings. + test('code-review workflow structural_pre_pass step is parseable and references FALLOW.json output', () => { + const workflow = fs.readFileSync( + path.join(ROOT, 'gsd-core', 'workflows', 'code-review.md'), + 'utf8', + ); + + // Parse: the block must exist and be closed + const stepMatch = workflow.match(/([\s\S]*?)<\/step>/); + assert.ok( + stepMatch, + 'workflow must contain a parseable ... block', + ); + + const stepBody = stepMatch[1]; + + // Structural property: the step body must reference the FALLOW.json output artifact + assert.ok( + stepBody.includes('FALLOW.json'), + 'structural_pre_pass step body must reference the FALLOW.json output artifact', + ); + + // Structural property: the step body must gate on the fallow enabled config key + assert.ok( + stepBody.includes('code_quality.fallow.enabled'), + 'structural_pre_pass step body must gate on code_quality.fallow.enabled', + ); + }); + + // B4: agent output contract — doc-parity check (approved fallback per config-schema-docs-parity + // pattern). We confirm the heading exists in the shipped artifact, not in a live agent response. + // Live agent output is covered by /gsd-code-review e2e runs downstream. + test('reviewer prompt defines ## Structural Findings (fallow) heading and review context echoes it', () => { + const reviewer = fs.readFileSync(path.join(ROOT, 'agents', 'gsd-code-reviewer.md'), 'utf8'); + const reviewContext = fs.readFileSync(path.join(ROOT, 'gsd-core', 'contexts', 'review.md'), 'utf8'); + + // Doc-parity: section heading must exist in the shipped agent file (the heading is a contract, + // not prose — renaming it would break every consumer that parses agent output by section) + assert.ok( + reviewer.includes('## Structural Findings (fallow)'), + 'gsd-code-reviewer.md must define ## Structural Findings (fallow) section heading', + ); + + // Doc-parity: review context that agents receive must reference the same section + assert.ok( + reviewContext.includes('Structural Findings (fallow)'), + 'review.md context must reference Structural Findings (fallow) so agents recognize the section', + ); + }); +}); + }); +} diff --git a/tests/config.test.cjs b/tests/config.test.cjs index cd16a1b92..2017b7974 100644 --- a/tests/config.test.cjs +++ b/tests/config.test.cjs @@ -2044,3 +2044,660 @@ describe('feat-3210 / H5: enum validation for code_quality.fallow.scope and .pro }); }); } + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3212-execute-phase-stall-safe-resume.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3212-execute-phase-stall-safe-resume (consolidation epic #1969 B3 #1972)", () => { +'use strict'; + +// allow-test-rule: source-text-is-product [#3212] +// The bug is in workflow/config contracts consumed by agents at runtime. + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { cleanup } = require('./helpers.cjs'); + +const ROOT = path.join(__dirname, '..'); + +function read(relativePath) { + return fs.readFileSync(path.join(ROOT, relativePath), 'utf8'); +} + +function runGsd(args, cwd) { + return spawnSync(process.execPath, [path.join(ROOT, 'gsd-core/bin/gsd-tools.cjs'), ...args], { + cwd, + encoding: 'utf8', + }); +} + +describe('bug #3212 execute-phase stall detection and safe resume', () => { + test('config schemas register executor stall detector keys', () => { + // After Cycle 5 (#3536), both CJS and SDK source from the manifest. + // Use the CJS runtime Set for CJS; use the manifest directly for SDK-side + // verification (since config-schema.ts no longer has inline literals). + const { VALID_CONFIG_KEYS: cjsKeys } = require('../gsd-core/bin/lib/config-schema.cjs'); + const manifest = JSON.parse(read('gsd-core/bin/shared/config-schema.manifest.json')); + const manifestKeys = new Set(manifest.validKeys); + + for (const key of ['executor.stall_detect_interval_minutes', 'executor.stall_threshold_minutes']) { + assert.ok(cjsKeys.has(key), `CJS VALID_CONFIG_KEYS must include ${key}`); + assert.ok(manifestKeys.has(key), `Manifest validKeys must include ${key} (SDK sources from manifest)`); + } + }); + + test('configuration docs describe stall detector defaults', () => { + const docs = read('docs/CONFIGURATION.md'); + + assert.match(docs, /`executor\.stall_detect_interval_minutes`\s*\|\s*number\s*\|\s*`5`/); + assert.match(docs, /`executor\.stall_threshold_minutes`\s*\|\s*number\s*\|\s*`10`/); + }); + + test('config-get returns schema defaults for executor stall detector keys', (t) => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3212-')); + t.after(() => cleanup(tmp)); + fs.mkdirSync(path.join(tmp, '.planning')); + fs.writeFileSync(path.join(tmp, '.planning/config.json'), '{}\n'); + + const interval = runGsd(['config-get', 'executor.stall_detect_interval_minutes', '--raw'], tmp); + const threshold = runGsd(['config-get', 'executor.stall_threshold_minutes', '--raw'], tmp); + + assert.equal(interval.status, 0, interval.stderr); + assert.equal(interval.stdout.trim(), '5'); + assert.equal(threshold.status, 0, threshold.stderr); + assert.equal(threshold.stdout.trim(), '10'); + }); + + test('execute-phase verifies partial-plan drift before dispatch', () => { + const workflow = read('gsd-core/workflows/execute-phase.md'); + + assert.match(workflow, / { + const workflow = read('gsd-core/workflows/execute-phase.md'); + + assert.match(workflow, /EXECUTOR_STALL_INTERVAL_MINUTES=.*executor\.stall_detect_interval_minutes/); + assert.match(workflow, /EXECUTOR_STALL_THRESHOLD_MINUTES=.*executor\.stall_threshold_minutes/); + assert.match(workflow, /DISPATCH_TS=/, 'execute-phase must record dispatch timestamp'); + assert.match(workflow, /EXPECTED_BRANCH=/, 'execute-phase must record expected branch'); + assert.match(workflow, /git log "\$\{EXPECTED_BRANCH\}" --since="\$\{DISPATCH_TS\}"/, 'stall check must inspect branch commits since dispatch'); + assert.match(workflow, /continue waiting/, 'stall warning must offer continue waiting'); + assert.match(workflow, /kill and retry/, 'stall warning must offer kill and retry'); + assert.match(workflow, /kill and switch to inline execution/, 'stall warning must offer inline fallback'); + }); + + test('execute-plan documents atomic close-out invariant', () => { + const workflow = read('gsd-core/workflows/execute-plan.md'); + + assert.match(workflow, //, 'execute-plan must contain a formal atomic close-out invariant'); + assert.match(workflow, /production-code commit\(s\) -> SUMMARY commit -> STATE\/ROADMAP update/, 'invariant must name the legal close-out sequence'); + assert.match(workflow, /only legal half-state is mid-production-commits/, 'invariant must define the only legal half-state'); + }); + + test('forensics includes the partial-plan drift detector', () => { + const workflow = read('gsd-core/workflows/forensics.md'); + + assert.match(workflow, /Partial-plan Drift Detection/); + assert.match(workflow, /commits exist but SUMMARY.md is missing/); + assert.match(workflow, /safe-resume verifier/); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3227-config-set-model-overrides.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3227-config-set-model-overrides (consolidation epic #1969 B3 #1972)", () => { +'use strict'; + +/** + * Regression test for bug #3227 — config-set rejects model_overrides.. + * + * `gsd-sdk query config-set model_overrides.gsd-plan-checker opus` was + * rejected with "Unknown config key" because `model_overrides.` was + * missing from DYNAMIC_KEY_PATTERNS in both the CJS schema and the SDK schema. + * + * The override mechanism itself worked correctly (resolve-model returned the + * override after a direct file edit). Only the write path was gated wrong. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs'); +const { DYNAMIC_KEY_PATTERNS, isValidConfigKey } = require('../gsd-core/bin/lib/config-schema.cjs'); + +describe('#3227 — config-set accepts model_overrides.', () => { + test('isValidConfigKey accepts model_overrides.gsd-plan-checker', () => { + assert.ok( + isValidConfigKey('model_overrides.gsd-plan-checker'), + 'model_overrides.gsd-plan-checker must be accepted by isValidConfigKey' + ); + }); + + test('isValidConfigKey accepts model_overrides with various agent-id formats', () => { + const validKeys = [ + 'model_overrides.gsd-executor', + 'model_overrides.gsd-planner', + 'model_overrides.gsd-codebase-mapper', + 'model_overrides.my_custom_agent', + 'model_overrides.agent123', + ]; + for (const key of validKeys) { + assert.ok(isValidConfigKey(key), `isValidConfigKey must accept ${key}`); + } + }); + + test('isValidConfigKey rejects bare model_overrides (no agent-id)', () => { + assert.ok( + !isValidConfigKey('model_overrides'), + 'bare model_overrides must be rejected (use model_overrides.)' + ); + }); + + test('DYNAMIC_KEY_PATTERNS includes an entry for model_overrides', () => { + const hasPattern = DYNAMIC_KEY_PATTERNS.some( + (p) => p.description && p.description.includes('model_overrides') + ); + assert.ok(hasPattern, 'DYNAMIC_KEY_PATTERNS must have an entry covering model_overrides.'); + }); + + test('config-set model_overrides.gsd-plan-checker opus succeeds via gsd-tools.cjs', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + + const result = runGsdTools( + ['config-set', 'model_overrides.gsd-plan-checker', 'opus'], + tmpDir + ); + assert.ok( + result.success, + [ + 'config-set model_overrides.gsd-plan-checker opus should succeed,', + 'got:', + 'stdout: ' + result.output, + 'stderr: ' + result.error, + ].join('\n') + ); + }); + + test('config-set model_overrides.gsd-plan-checker opus writes to config.json', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + + runGsdTools(['config-set', 'model_overrides.gsd-plan-checker', 'opus'], tmpDir); + + const configPath = path.join(tmpDir, '.planning', 'config.json'); + assert.ok(fs.existsSync(configPath), '.planning/config.json must exist after config-set'); + + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + assert.ok( + config.model_overrides !== undefined && + config.model_overrides['gsd-plan-checker'] === 'opus', + [ + 'Expected model_overrides["gsd-plan-checker"]: "opus" in config.json,', + 'got: ' + JSON.stringify(config), + ].join('\n') + ); + }); + + test('config-get model_overrides.gsd-plan-checker returns opus after config-set', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + + runGsdTools(['config-set', 'model_overrides.gsd-plan-checker', 'opus'], tmpDir); + + const getResult = runGsdTools( + ['config-get', 'model_overrides.gsd-plan-checker'], + tmpDir + ); + assert.ok( + getResult.success, + [ + 'config-get model_overrides.gsd-plan-checker should succeed,', + 'got:', + 'stdout: ' + getResult.output, + 'stderr: ' + getResult.error, + ].join('\n') + ); + assert.ok( + getResult.output.includes('opus'), + 'config-get output should contain "opus", got: ' + getResult.output + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/feat-1452-context-guard-mode.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:feat-1452-context-guard-mode (consolidation epic #1969 B3 #1972)", () => { +// allow-test-rule: source-text-is-the-product see #1452 +// The execute-phase.md workflow and context-budget.md reference ARE the runtime +// contract loaded by AI runtimes. Asserting that the canonical wording for +// `workflow.context_guard_mode` is present in those files is the only way to +// verify runtimes will respect the flag at runtime. + +/** + * Enhancement #1452: workflow.context_guard_mode + * + * Guards long execute-phase workflows from driving the host session to context + * exhaustion (ctx 100%). Before each wave, the orchestrator self-assesses + * context pressure using the degradation signals defined in context-budget.md. + * + * Modes: + * "warn" (default) — emit a structured warning + recommend /gsd:pause-work + * "auto" — auto-invoke pause-work before the next wave + * "off" — disable the guard entirely + * + * The check fires at wave boundaries ONLY (before spawning), never mid-wave. + */ + +'use strict'; + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); + +function readConfig(tmpDir) { + const configPath = path.join(tmpDir, '.planning', 'config.json'); + return JSON.parse(fs.readFileSync(configPath, 'utf-8')); +} + +const REPO_ROOT = path.join(__dirname, '..'); + +// ─── Schema registration ────────────────────────────────────────────────────── + +describe('workflow.context_guard_mode in VALID_CONFIG_KEYS', () => { + test('is a recognized config key', () => { + const { VALID_CONFIG_KEYS } = require('../gsd-core/bin/lib/config.cjs'); + assert.ok( + VALID_CONFIG_KEYS.has('workflow.context_guard_mode'), + 'workflow.context_guard_mode should be in VALID_CONFIG_KEYS', + ); + }); +}); + +// ─── Default value ──────────────────────────────────────────────────────────── + +describe('workflow.context_guard_mode default value', () => { + let tmpDir; + beforeEach(() => { tmpDir = createTempProject(); }); + afterEach(() => { cleanup(tmpDir); }); + + test('defaults to warn in new project config', () => { + const result = runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `config-ensure-section failed: ${result.error}`); + + const config = readConfig(tmpDir); + assert.strictEqual( + config.workflow.context_guard_mode, + 'warn', + 'workflow.context_guard_mode should default to "warn" — proactive checkpoint warning without auto-pausing workflows', + ); + }); +}); + +// ─── Round-trip ────────────────────────────────────────────────────────────── + +describe('workflow.context_guard_mode config round-trip', () => { + let tmpDir; + beforeEach(() => { + tmpDir = createTempProject(); + runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir }); + }); + afterEach(() => { cleanup(tmpDir); }); + + test('config-set warn persists to config.json', () => { + const setResult = runGsdTools('config-set workflow.context_guard_mode warn', tmpDir); + assert.ok(setResult.success, `config-set failed: ${setResult.error}`); + + const config = readConfig(tmpDir); + assert.strictEqual(config.workflow.context_guard_mode, 'warn'); + }); + + test('config-set auto persists to config.json', () => { + const setResult = runGsdTools('config-set workflow.context_guard_mode auto', tmpDir); + assert.ok(setResult.success, `config-set failed: ${setResult.error}`); + + const config = readConfig(tmpDir); + assert.strictEqual(config.workflow.context_guard_mode, 'auto'); + }); + + test('config-set off persists to config.json', () => { + const setResult = runGsdTools('config-set workflow.context_guard_mode off', tmpDir); + assert.ok(setResult.success, `config-set failed: ${setResult.error}`); + + const config = readConfig(tmpDir); + assert.strictEqual(config.workflow.context_guard_mode, 'off'); + }); + + test('persists in config.json as string', () => { + runGsdTools('config-set workflow.context_guard_mode warn', tmpDir); + + const config = readConfig(tmpDir); + assert.strictEqual(config.workflow.context_guard_mode, 'warn'); + assert.strictEqual(typeof config.workflow.context_guard_mode, 'string'); + }); + + test('rejects unknown mode values with clear error', () => { + const result = runGsdTools('config-set workflow.context_guard_mode aggressive', tmpDir); + assert.strictEqual(result.success, false); + assert.match(result.error, /Invalid workflow\.context_guard_mode 'aggressive'/); + assert.match(result.error, /auto, warn, off/); + }); + + test('rejects partial match values', () => { + const result = runGsdTools('config-set workflow.context_guard_mode warnmode', tmpDir); + assert.strictEqual(result.success, false); + assert.match(result.error, /Invalid workflow\.context_guard_mode 'warnmode'/); + }); +}); + +// ─── execute-phase contract ─────────────────────────────────────────────────── + +describe('execute-phase.md documents the context_guard step', () => { + let executePhase; + let contextGuardRef; + + beforeEach(() => { + executePhase = fs.readFileSync( + path.join(REPO_ROOT, 'gsd-core', 'workflows', 'execute-phase.md'), + 'utf-8', + ); + // The step body is extracted to a reference file loaded via @-ref in execute-phase.md. + // Both files together constitute the execute-phase wave-boundary contract. + const refPath = path.join(REPO_ROOT, 'gsd-core', 'references', 'execute-phase-context-guard.md'); + contextGuardRef = fs.existsSync(refPath) ? fs.readFileSync(refPath, 'utf-8') : ''; + }); + + test('references workflow.context_guard_mode by canonical name', () => { + const combined = executePhase + '\n' + contextGuardRef; + assert.ok( + combined.includes('workflow.context_guard_mode'), + 'execute-phase.md (or its @-referenced execute-phase-context-guard.md) must reference workflow.context_guard_mode so runtimes resolve the config-driven behavior', + ); + }); + + test('defines context_guard step at wave boundaries', () => { + assert.ok( + executePhase.includes('context_guard') || executePhase.includes('context-guard'), + 'execute-phase.md must define a context_guard step (or @-ref to it) that fires before each wave', + ); + }); + + test('references context-budget.md tiers in the guard step', () => { + const combined = executePhase + '\n' + contextGuardRef; + assert.ok( + combined.includes('context-budget') || combined.includes('POOR') || combined.includes('DEGRADING'), + 'execute-phase.md context_guard (or its @-referenced file) must reference context-budget.md degradation tiers', + ); + }); +}); + +// ─── context-budget.md contract ────────────────────────────────────────────── + +describe('context-budget.md documents POOR-tier trigger action', () => { + let contextBudget; + + beforeEach(() => { + contextBudget = fs.readFileSync( + path.join(REPO_ROOT, 'gsd-core', 'references', 'context-budget.md'), + 'utf-8', + ); + }); + + test('defines POOR tier', () => { + assert.ok( + contextBudget.includes('POOR'), + 'context-budget.md must define the POOR tier', + ); + }); + + test('connects POOR tier to pause-work', () => { + assert.ok( + contextBudget.includes('pause-work') || contextBudget.includes('pause_work'), + 'context-budget.md POOR-tier rule must reference pause-work as the trigger action', + ); + }); + + test('documents context_guard_mode values', () => { + assert.ok( + contextBudget.includes('context_guard_mode'), + 'context-budget.md must document the workflow.context_guard_mode config key', + ); + }); +}); + +// ─── planning-config.md reference parity ───────────────────────────────────── + +describe('planning-config.md documents workflow.context_guard_mode', () => { + test('includes the key in the reference table', () => { + const planningConfig = fs.readFileSync( + path.join(REPO_ROOT, 'gsd-core', 'references', 'planning-config.md'), + 'utf-8', + ); + assert.ok( + planningConfig.includes('workflow.context_guard_mode'), + 'planning-config.md reference must include workflow.context_guard_mode so users know the config knob exists', + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/feat-3309-human-verify-mode.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:feat-3309-human-verify-mode (consolidation epic #1969 B3 #1972)", () => { +// allow-test-rule: source-text-is-the-product (see #3309) +// Planner and verifier agent .md files ARE the runtime contract loaded by +// the AI runtimes. Asserting that the canonical wording for the new +// `workflow.human_verify_mode` flag is present in those files is the only +// way to verify the agents will respect the flag at runtime. + +/** + * Enhancement #3309: workflow.human_verify_mode = end-of-phase + * + * "mid-flight" preserves the pre-#3309 behavior — the planner emits + * `` tasks, and the executor halts at + * each one. Each halt costs a full executor cold-start (CLAUDE.md, MEMORY.md, + * STATE.md, plan re-read) because subagent context is discarded across the + * pause. + * + * "end-of-phase" (the new default) instructs the planner NOT to emit + * `checkpoint:human-verify` tasks and instead embed the verification details + * into the relevant `auto` task's `` block. The verifier + * (Step 8) harvests these blocks at end-of-phase and consolidates them into the existing + * `human_needed` → HUMAN-UAT.md path, restoring the v1.35-shaped behavior + * the reporter wanted without resurrecting the v1.35 writer. + */ + +'use strict'; + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); + +function readConfig(tmpDir) { + const configPath = path.join(tmpDir, '.planning', 'config.json'); + return JSON.parse(fs.readFileSync(configPath, 'utf-8')); +} + +const REPO_ROOT = path.join(__dirname, '..'); + +// ─── Schema registration ────────────────────────────────────────────────────── + +describe('workflow.human_verify_mode in VALID_CONFIG_KEYS', () => { + test('is a recognized config key', () => { + const { VALID_CONFIG_KEYS } = require('../gsd-core/bin/lib/config.cjs'); + assert.ok( + VALID_CONFIG_KEYS.has('workflow.human_verify_mode'), + 'workflow.human_verify_mode should be in VALID_CONFIG_KEYS', + ); + }); +}); + +// ─── Default value (CJS) ────────────────────────────────────────────────────── + +describe('workflow.human_verify_mode default value', () => { + let tmpDir; + beforeEach(() => { tmpDir = createTempProject(); }); + afterEach(() => { cleanup(tmpDir); }); + + test('defaults to end-of-phase in new project config', () => { + const result = runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `config-ensure-section failed: ${result.error}`); + + const config = readConfig(tmpDir); + assert.strictEqual( + config.workflow.human_verify_mode, + 'end-of-phase', + 'workflow.human_verify_mode should default to "end-of-phase" — the cost-control mode is the project default; opt back into the pre-#3309 mid-flight behavior with config-set', + ); + }); +}); + +// ─── Round-trip ────────────────────────────────────────────────────────────── + +describe('workflow.human_verify_mode config round-trip', () => { + let tmpDir; + beforeEach(() => { + tmpDir = createTempProject(); + runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir }); + }); + afterEach(() => { cleanup(tmpDir); }); + + test('config-set end-of-phase persists to config.json', () => { + const setResult = runGsdTools('config-set workflow.human_verify_mode end-of-phase', tmpDir); + assert.ok(setResult.success, `config-set failed: ${setResult.error}`); + + const config = readConfig(tmpDir); + assert.strictEqual(config.workflow.human_verify_mode, 'end-of-phase'); + }); + + test('config-set mid-flight overwrites end-of-phase in config.json', () => { + runGsdTools('config-set workflow.human_verify_mode end-of-phase', tmpDir); + + const setResult = runGsdTools('config-set workflow.human_verify_mode mid-flight', tmpDir); + assert.ok(setResult.success, `config-set failed: ${setResult.error}`); + + const config = readConfig(tmpDir); + assert.strictEqual(config.workflow.human_verify_mode, 'mid-flight'); + }); + + test('persists in config.json as string', () => { + runGsdTools('config-set workflow.human_verify_mode end-of-phase', tmpDir); + + const config = readConfig(tmpDir); + assert.strictEqual(config.workflow.human_verify_mode, 'end-of-phase'); + assert.strictEqual(typeof config.workflow.human_verify_mode, 'string'); + }); + + test('rejects invalid mode values', () => { + const result = runGsdTools('config-set workflow.human_verify_mode midflight', tmpDir); + assert.strictEqual(result.success, false); + assert.match(result.error, /Invalid workflow\.human_verify_mode 'midflight'/); + assert.match(result.error, /mid-flight, end-of-phase/); + }); +}); + +// ─── Planner agent contract ────────────────────────────────────────────────── + +describe('agents/gsd-planner.md acknowledges workflow.human_verify_mode', () => { + let plannerSrc; + + test('loads', () => { + plannerSrc = fs.readFileSync(path.join(REPO_ROOT, 'agents', 'gsd-planner.md'), 'utf-8'); + assert.ok(plannerSrc.length > 0); + }); + + test('mentions workflow.human_verify_mode by canonical name', () => { + plannerSrc = plannerSrc || fs.readFileSync(path.join(REPO_ROOT, 'agents', 'gsd-planner.md'), 'utf-8'); + assert.ok( + plannerSrc.includes('workflow.human_verify_mode'), + 'planner must reference the flag by canonical key so the runtime can resolve config-driven behavior', + ); + }); + + test('explains the end-of-phase behavior (do NOT emit checkpoint:human-verify)', () => { + plannerSrc = plannerSrc || fs.readFileSync(path.join(REPO_ROOT, 'agents', 'gsd-planner.md'), 'utf-8'); + // The planner must instruct: when end-of-phase, do NOT emit checkpoint:human-verify + assert.ok( + /end-of-phase[\s\S]{0,400}checkpoint:human-verify/i.test(plannerSrc) || + /checkpoint:human-verify[\s\S]{0,400}end-of-phase/i.test(plannerSrc), + 'planner must couple "end-of-phase" mode with the rule that checkpoint:human-verify tasks are not emitted', + ); + }); + + test('routes deferred verification through the block on auto tasks', () => { + plannerSrc = plannerSrc || fs.readFileSync(path.join(REPO_ROOT, 'agents', 'gsd-planner.md'), 'utf-8'); + assert.ok( + /`?`?\s*[\s\S]{0,200}`?`?/i.test(plannerSrc) || + plannerSrc.includes('') || + plannerSrc.includes('``'), + 'planner must document the ... shape so the verifier can harvest deferred items', + ); + }); +}); + +// ─── Verifier agent contract ───────────────────────────────────────────────── + +describe('agents/gsd-verifier.md harvests deferred human verification items', () => { + test('Step 8 mentions harvesting blocks from PLAN.md', () => { + const verifierSrc = fs.readFileSync(path.join(REPO_ROOT, 'agents', 'gsd-verifier.md'), 'utf-8'); + assert.ok( + verifierSrc.includes('') || /[\s\S]{0,200}/i.test(verifierSrc), + 'verifier must instruct itself to harvest blocks from PLAN.md when human_verify_mode = end-of-phase', + ); + assert.ok( + verifierSrc.includes('human_verify_mode'), + 'verifier must reference the flag by canonical key', + ); + }); +}); + +// ─── References doc parity ─────────────────────────────────────────────────── + +describe('references/checkpoints.md documents the flag', () => { + test('mentions workflow.human_verify_mode in the human-verify section', () => { + const refSrc = fs.readFileSync( + path.join(REPO_ROOT, 'gsd-core', 'references', 'checkpoints.md'), + 'utf-8', + ); + assert.ok( + refSrc.includes('workflow.human_verify_mode'), + 'checkpoints reference must document the new flag so users know the cost-control alternative exists', + ); + }); +}); + }); +} diff --git a/tests/configuration-migrate-config.test.cjs b/tests/configuration-migrate-config.test.cjs index 460c6005f..3ea1537c3 100644 --- a/tests/configuration-migrate-config.test.cjs +++ b/tests/configuration-migrate-config.test.cjs @@ -176,3 +176,36 @@ describe('migrate-config — idempotent (running twice produces no-op)', () => { assert.deepEqual(secondParsed.normalizations, [], 'Second run normalizations must be empty'); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-321-config-defaults-clone-strategy.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-321-config-defaults-clone-strategy (consolidation epic #1969 B3 #1972)", () => { +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const configuration = require('../gsd-core/bin/lib/configuration.cjs'); + +test('mergeDefaults clones defaults without JSON serialization fragility (#321)', () => { + const sentinelKey = '__bug321_bigint_sentinel__'; + const sentinelValue = BigInt('9007199254740993001'); + + configuration.CONFIG_DEFAULTS[sentinelKey] = sentinelValue; + try { + const merged = configuration.mergeDefaults({}); + assert.equal( + merged[sentinelKey], + sentinelValue, + 'mergeDefaults must preserve non-JSON scalar defaults when cloning' + ); + } + finally { + delete configuration.CONFIG_DEFAULTS[sentinelKey]; + } +}); + }); +} diff --git a/tests/declarative-reference-antigravity.test.cjs b/tests/declarative-reference-antigravity.test.cjs index b5d3b55b2..e7e7ee706 100644 --- a/tests/declarative-reference-antigravity.test.cjs +++ b/tests/declarative-reference-antigravity.test.cjs @@ -65,3 +65,262 @@ test('a real Antigravity install emits a gsd command/skill surface (invocable)', cleanup(root); } }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3608-antigravity-update-runtime-classification.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3608-antigravity-update-runtime-classification (consolidation epic #1969 B3 #1972)", () => { +/** + * Bug #3608: /gsd:update must model Antigravity as a first-class runtime, so an + * Antigravity install (~/.gemini/antigravity*) is not misclassified as base + * Gemini. + * + * The installer (bin/install.js) and SDK already treat Antigravity as a distinct + * runtime with its own config dirs, env var (ANTIGRAVITY_CONFIG_DIR), and CLI + * flag (--antigravity). The update flow must agree. + * + * Relocation (#498): the update flow's runtime/scope detection moved out of + * ~280 lines of inline bash in update.md into the tested projection + * `gsd-core/bin/lib/update-context.cjs` (resolveUpdateContext). The + * antigravity-first-class contract now lives there as data + behavior, so this + * test asserts it on the projection. The only piece still authored in update.md + * is the execution_context path classification (prose the agent applies), which + * this test still checks for antigravity-before-gemini ordering. + * + * Order matters: every probe list / env ladder that contains a Gemini entry + * MUST place the more-specific Antigravity entry first, else an install with + * both signals present falls through to gemini. + */ + +'use strict'; +process.env.GSD_TEST_MODE = '1'; + +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 { + RUNTIME_DIRS, + inferPreferredRuntime, + envRuntimeDirs, + resolveUpdateContext, +} = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'update-context.cjs')); +const UPDATE_MD = path.join(ROOT, 'gsd-core', 'workflows', 'update.md'); + +function runtimeOrder() { + return RUNTIME_DIRS.map(([rt]) => rt); +} +function firstIndex(arr, token) { + return arr.indexOf(token); +} + +describe('bug #3608 / #498: update-context models Antigravity as a first-class runtime', () => { + test('RUNTIME_DIRS lists antigravity before gemini', () => { + const order = runtimeOrder(); + const antIdx = firstIndex(order, 'antigravity'); + const gemIdx = firstIndex(order, 'gemini'); + assert.notStrictEqual(antIdx, -1, 'RUNTIME_DIRS missing antigravity'); + assert.notStrictEqual(gemIdx, -1, 'RUNTIME_DIRS missing gemini'); + assert.ok(antIdx < gemIdx, `antigravity (@${antIdx}) must precede gemini (@${gemIdx}) — first match wins`); + }); + + test('RUNTIME_DIRS includes antigravity 2.x (ide/cli) + legacy dirs', () => { + const dirs = RUNTIME_DIRS.filter(([rt]) => rt === 'antigravity').map(([, d]) => d); + assert.ok(dirs.includes('.gemini/antigravity-ide'), 'missing .gemini/antigravity-ide'); + assert.ok(dirs.includes('.gemini/antigravity-cli'), 'missing .gemini/antigravity-cli'); + assert.ok(dirs.includes('.gemini/antigravity'), 'missing legacy .gemini/antigravity fallback'); + // All antigravity dirs precede the .gemini probe. + const order = RUNTIME_DIRS.map(([, d]) => d); + const gemIdx = order.indexOf('.gemini'); + for (const d of dirs) { + assert.ok(order.indexOf(d) < gemIdx, `${d} must precede .gemini in the probe order`); + } + }); + + test('env inference recognizes ANTIGRAVITY_CONFIG_DIR before GEMINI_CONFIG_DIR', () => { + const rt = inferPreferredRuntime({ + fs: { exists: () => false }, + env: { ANTIGRAVITY_CONFIG_DIR: '/x', GEMINI_CONFIG_DIR: '/y' }, + preferredConfigDir: '', + }); + assert.equal(rt, 'antigravity', 'both env vars set must resolve to antigravity, not gemini'); + }); + + test('envRuntimeDirs emits an antigravity entry (before gemini) when ANTIGRAVITY_CONFIG_DIR is set', () => { + const entries = envRuntimeDirs({ env: { ANTIGRAVITY_CONFIG_DIR: '/x/ag', GEMINI_CONFIG_DIR: '/x/gem' }, home: '/home/u' }); + const order = entries.map(([rt]) => rt); + assert.ok(order.includes('antigravity'), 'expected an antigravity env candidate'); + assert.ok(order.indexOf('antigravity') < order.indexOf('gemini'), 'antigravity env candidate must precede gemini'); + }); + + test('behavioral: an Antigravity install resolves to runtime "antigravity", not "gemini"', () => { + // Normalize paths so the fake fs matches the resolver's path.join/resolve + // lookups on Windows (backslash + drive) as well as POSIX. + const normKey = (p) => path.resolve(p).replace(/\\/g, '/').toLowerCase(); + const HOME = '/home/u'; + const agDir = path.join(HOME, '.gemini', 'antigravity'); + const verFile = normKey(path.join(agDir, 'gsd-core', 'VERSION')); + const markerFile = normKey(path.join(agDir, 'gsd-core', 'workflows', 'update.md')); + const fakeFs = { + exists: (p) => normKey(p) === verFile || normKey(p) === markerFile, + readFile: (p) => (normKey(p) === verFile ? '1.40.0\n' : null), + }; + const r = resolveUpdateContext({ home: HOME, cwd: path.resolve('/work'), env: {}, fs: fakeFs }); + assert.equal(r.runtime, 'antigravity'); + assert.equal(normKey(r.gsdDir), normKey(agDir)); + }); + + test('update.md execution_context classification still lists antigravity paths before /.gemini/', () => { + const content = fs.readFileSync(UPDATE_MD, 'utf-8'); + const antIde = content.indexOf('/.gemini/antigravity-ide/'); + const gemBare = content.indexOf('`/.gemini/` -> `gemini`'); + assert.notStrictEqual(antIde, -1, 'update.md must document the antigravity-ide execution_context path'); + assert.notStrictEqual(gemBare, -1, 'update.md must document the bare /.gemini/ -> gemini classification'); + assert.ok(antIde < gemBare, 'antigravity path classification must precede the bare /.gemini/ rule'); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-503-update-agent-antigravity-detection.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-503-update-agent-antigravity-detection (consolidation epic #1969 B3 #1972)", () => { +'use strict'; +process.env.GSD_TEST_MODE = '1'; + +// allow-test-rule: source-text-is-the-product (see #503) +// update.md's embedded classifier + cache-clear loop are workflow text the +// runtime loads and executes, so asserting on that text tests deployed +// behavior. The runtime/scope detection cascade itself moved out of inline +// bash into the update-context projection (issue #498), so the core guarantee +// is exercised behaviorally against resolveUpdateContext rather than by +// matching a `RUNTIME_DIRS=(...)` literal that no longer lives in update.md. +// Per CONTRIBUTING.md exception matrix. + +/** + * Bug #503: /gsd:update misclassifies local Antigravity (.agent) installs as claude + * + * The installer places a LOCAL Antigravity install in ./.agent/ + * (bin/install.js: getDirName('antigravity') === '.agent'). The /gsd:update + * detection cascade must map .agent -> antigravity across three surfaces: + * 1. the execution_context path classifier (update.md prose), + * 2. the RUNTIME_DIRS candidate table (now in the update-context projection), + * 3. the post-update cache-clear `for dir in` loop (update.md). + * + * Surface (2) is the original root cause and is now verified behaviorally: a + * LOCAL .agent install must resolve to the antigravity runtime. Before the fix + * (.agent absent from RUNTIME_DIRS) it fell through to UNKNOWN/claude. + */ + +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 UPDATE_MD = fs.readFileSync( + path.join(ROOT, 'gsd-core', 'workflows', 'update.md'), + 'utf-8', +); +const { resolveUpdateContext } = require( + path.join(ROOT, 'gsd-core', 'bin', 'lib', 'update-context.cjs'), +); + +function normKey(p) { return path.resolve(p).replace(/\\/g, '/').toLowerCase(); } +function fakeFs(files) { + const set = new Map(); + for (const [k, v] of Object.entries(files)) set.set(normKey(k), v); + return { + exists: (p) => set.has(normKey(p)), + readFile: (p) => { const k = normKey(p); return set.has(k) ? set.get(k) : null; }, + }; +} + +describe('/gsd:update detects local Antigravity (.agent / .agents) installs (#503 / #791)', () => { + test('projection resolves a LOCAL ./.agents install to the antigravity runtime (#791 canonical)', () => { + const HOME = '/home/u'; + const CWD = '/work/proj'; + const agentsDir = `${CWD}/.agents`; + const ffs = fakeFs({ + [`${agentsDir}/gsd-core/VERSION`]: '1.50.0\n', + [`${agentsDir}/gsd-core/workflows/update.md`]: 'x', + }); + const r = resolveUpdateContext({ home: HOME, cwd: CWD, env: {}, fs: ffs }); + assert.equal( + r.runtime, + 'antigravity', + `a local .agents install must map to the antigravity runtime, got "${r.runtime}"`, + ); + assert.equal(r.scope, 'LOCAL'); + assert.equal(r.installedVersion, '1.50.0'); + }); + + test('projection resolves a LOCAL ./.agent install to the antigravity runtime (#503 backward-compat)', () => { + const HOME = '/home/u'; + const CWD = '/work/proj'; + const agentDir = `${CWD}/.agent`; + const ffs = fakeFs({ + [`${agentDir}/gsd-core/VERSION`]: '1.40.0\n', + [`${agentDir}/gsd-core/workflows/update.md`]: 'x', + }); + const r = resolveUpdateContext({ home: HOME, cwd: CWD, env: {}, fs: ffs }); + assert.equal( + r.runtime, + 'antigravity', + `a legacy .agent install must still map to the antigravity runtime, got "${r.runtime}"`, + ); + assert.equal(r.scope, 'LOCAL'); + assert.equal(r.installedVersion, '1.40.0'); + }); + + test('execution_context classifier maps /.agents/ and /.agent/ paths to antigravity (update.md)', () => { + const hasAgentsClassifierRule = + /\/\.agents\/[^\r\n]*->[^\r\n]*antigravity/.test(UPDATE_MD); + assert.ok( + hasAgentsClassifierRule, + 'update.md classifier must map a `/.agents/` path to the `antigravity` runtime', + ); + const hasAgentClassifierRule = + /\/\.agent\/[^\r\n]*->[^\r\n]*antigravity/.test(UPDATE_MD); + assert.ok( + hasAgentClassifierRule, + 'update.md classifier must still map a `/.agent/` path to the `antigravity` runtime (backward-compat)', + ); + }); + + test('every runtime-dir `for dir in` loop in update.md includes .agents and .agent', () => { + // The LOCAL-scope discovery loop moved into the projection (#498); the + // post-update cache-clear loop remains inline and still enumerates the + // runtime config dirs as a literal `.claude ... .codex` list, so it must + // include both .agents (canonical, #791) and .agent (legacy, #503) or + // stale indicators could linger. + const runtimeDirLoops = UPDATE_MD + .split(/\r?\n/) + .filter((l) => /for dir in .*\.claude.*\.codex/.test(l)); + assert.ok( + runtimeDirLoops.length >= 1, + `expected at least 1 runtime-dir loop in update.md, found ${runtimeDirLoops.length}`, + ); + for (const loop of runtimeDirLoops) { + assert.ok( + /(^|\s)\.agents(\s|$)/.test(loop), + `every runtime-dir loop must include .agents (canonical), got: ${loop.trim()}`, + ); + assert.ok( + /(^|\s)\.agent(\s|$)/.test(loop), + `every runtime-dir loop must include .agent (legacy backward-compat), got: ${loop.trim()}`, + ); + } + }); +}); + }); +} diff --git a/tests/docs-parity-live-registry.test.cjs b/tests/docs-parity-live-registry.test.cjs index c45ba5a35..eb9e66b1f 100644 --- a/tests/docs-parity-live-registry.test.cjs +++ b/tests/docs-parity-live-registry.test.cjs @@ -542,3 +542,196 @@ describe('extractCommandTokens() — repo-path false-positive regression (#489)' ); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2954-help-md-slash-command-stubs.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2954-help-md-slash-command-stubs (consolidation epic #1969 B3 #1972)", () => { +// allow-test-rule: source-text-is-the-product (see #2954) +// 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'; + + +process.env.GSD_TEST_MODE = '1'; + +/** + * Bug #2954: keep `help.md` and the live `commands/gsd/*` slash surface + * in lockstep. Two regression tests: + * + * 1. help.md must not advertise any /gsd[-:] that has no shipped + * slash command. (Caught the original #2954 regression: #2824 deleted + * 31 stubs without updating help.md.) + * + * 2. Every shipped /gsd[-:] command must appear in help.md. (Caught + * the inverse: a command lands without docs, so users never discover it.) + * + * The shipped slash name is parsed from frontmatter `name:` (which can be + * either `gsd:foo` or `gsd-foo` — Claude Code surfaces both as `/gsd-foo`), + * NOT from the filename, because some files (e.g. `ns-context.md`) ship a + * different slash name (`gsd-context`) than their filename suggests. + * + * Also covers `do.md`, the dispatcher invoked at runtime by + * `/gsd:progress --do`: any `/gsd[-:]` token in its routing table must + * resolve to a live command, otherwise the dispatcher emits "Unknown command". + */ + +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 COMMANDS_DIR = path.join(ROOT, 'commands', 'gsd'); +// After #3039, the canonical command reference is the `--full` mode file. +// `workflows/help.md` is now a small dispatcher; the bidirectional parity +// invariant lives with the comprehensive reference body. +const HELP_MD = path.join(ROOT, 'gsd-core', 'workflows', 'help', 'modes', 'full.md'); +const DO_MD = path.join(ROOT, 'gsd-core', 'workflows', 'do.md'); + +function parseFrontmatter(content) { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!match) return null; + const fields = {}; + for (const line of match[1].split(/\r?\n/)) { + const fieldMatch = line.match(/^([a-zA-Z0-9_-]+):\s*(.*)$/); + if (!fieldMatch) continue; + const value = fieldMatch[2].trim().replace(/^["']|["']$/g, ''); + fields[fieldMatch[1]] = value; + } + return fields; +} + +/** + * Returns the set of slash-base-names actually shipped under commands/gsd/. + * A "slash-base-name" is the part after `/gsd-` — e.g. for frontmatter + * `name: gsd:foo` or `name: gsd-foo`, the slash-base-name is `foo`. + */ +function listShippedSlashBaseNames() { + const names = new Set(); + for (const entry of fs.readdirSync(COMMANDS_DIR, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith('.md')) continue; + const content = fs.readFileSync(path.join(COMMANDS_DIR, entry.name), 'utf8'); + const fm = parseFrontmatter(content); + if (!fm || !fm.name) continue; + const fmName = fm.name; + let base = null; + if (fmName.startsWith('gsd:')) base = fmName.slice(4); + else if (fmName.startsWith('gsd-')) base = fmName.slice(4); + if (base && /^[a-z][a-z0-9-]*$/.test(base)) names.add(base); + } + return names; +} + +function extractSlashReferences(contents) { + const names = new Set(); + // Negative lookbehind: must not be preceded by a letter (avoids matching npm scope + // paths like @opengsd/gsd-core where `/gsd-` appears inside a package URL). + // Negative lookahead (?![\w-]*\/): excludes filesystem path segments like + // `/gsd-core/bin` where the captured name is followed by a `/`, which would + // be a directory segment rather than a slash command name. + const tokenRe = /(?>. Flags are recorded without their leading `--`. + */ +function listShippedFlagsByCommand() { + const out = new Map(); + for (const entry of fs.readdirSync(COMMANDS_DIR, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith('.md')) continue; + const content = fs.readFileSync(path.join(COMMANDS_DIR, entry.name), 'utf8'); + const fm = parseFrontmatter(content); + if (!fm || !fm.name || !fm['argument-hint']) continue; + const fmName = fm.name; + let base = null; + if (fmName.startsWith('gsd:')) base = fmName.slice(4); + else if (fmName.startsWith('gsd-')) base = fmName.slice(4); + if (!base || !/^[a-z][a-z0-9-]*$/.test(base)) continue; + const flags = new Set(); + for (const m of fm['argument-hint'].matchAll(/--([a-z][a-z0-9-]*)/g)) { + flags.add(m[1]); + } + if (flags.size) out.set(base, flags); + } + return out; +} + +describe('Bug #2954: help.md ↔ commands/gsd/ bidirectional parity', () => { + test('every /gsd[-:] referenced in help.md is a shipped command', () => { + const helpContents = fs.readFileSync(HELP_MD, 'utf8'); + const referenced = extractSlashReferences(helpContents); + const shipped = listShippedSlashBaseNames(); + const dangling = [...referenced].filter((n) => !shipped.has(n)).sort(); + assert.deepEqual( + dangling, + [], + `help.md advertises /gsd[-:] commands that are not shipped: ${dangling.join(', ')}`, + ); + }); + + test('every shipped /gsd[-:] command is documented in help.md', () => { + const helpContents = fs.readFileSync(HELP_MD, 'utf8'); + const referenced = extractSlashReferences(helpContents); + const shipped = listShippedSlashBaseNames(); + const undocumented = [...shipped].filter((n) => !referenced.has(n)).sort(); + assert.deepEqual( + undocumented, + [], + `commands shipped under commands/gsd/ with no /gsd[-:] reference in help.md: ${undocumented.join(', ')}`, + ); + }); + + test('every /gsd[-:] in do.md (live dispatcher) is a shipped command', () => { + const doContents = fs.readFileSync(DO_MD, 'utf8'); + const referenced = extractSlashReferences(doContents); + const shipped = listShippedSlashBaseNames(); + const dangling = [...referenced].filter((n) => !shipped.has(n)).sort(); + assert.deepEqual( + dangling, + [], + `do.md routing table references /gsd[-:] that is not shipped: ${dangling.join(', ')}`, + ); + }); + + test('every --flag in a command\'s argument-hint appears in help.md', () => { + const helpContents = fs.readFileSync(HELP_MD, 'utf8'); + const flagsByCommand = listShippedFlagsByCommand(); + const gaps = []; + for (const [command, flags] of flagsByCommand) { + for (const flag of flags) { + // Accept `/gsd- --` (precise) OR a bare `--` token + // anywhere in help.md (good enough for shared flags like `--force` that + // appear under multiple commands' descriptions). + const preciseDash = `/gsd-${command} --${flag}`; + const preciseColon = `/gsd:${command} --${flag}`; + const flagToken = `--${flag}`; + if ( + !helpContents.includes(preciseDash) && + !helpContents.includes(preciseColon) && + !helpContents.includes(flagToken) + ) { + gaps.push(`/gsd:${command} --${flag}`); + } + } + } + assert.deepEqual( + gaps.sort(), + [], + `commands ship --flag(s) in argument-hint that are absent from help.md: ${gaps.join(', ')}`, + ); + }); +}); + }); +} diff --git a/tests/docs-update.test.cjs b/tests/docs-update.test.cjs index 5f054106f..858c796c6 100644 --- a/tests/docs-update.test.cjs +++ b/tests/docs-update.test.cjs @@ -270,3 +270,143 @@ describe('doc tooling detection', () => { assert.strictEqual(data.doc_tooling.mkdocs, true); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3135-capture-backlog-workflow.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3135-capture-backlog-workflow (consolidation epic #1969 B3 #1972)", () => { +// allow-test-rule: source-text-is-the-product — workflow and command .md files (see #3135) +// 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 #3135. +// +// PR #2824 consolidated add-backlog into `gsd-capture --backlog` by creating +// a routing wrapper in commands/gsd/capture.md that delegates to +// workflows/add-backlog.md via execution_context. The workflow file was never +// created. Same gap class as reapply-patches.md (found and fixed in the same PR). +// +// Fix: create gsd-core/workflows/add-backlog.md with the full process +// ported from the deleted commands/gsd/add-backlog.md (git ref 87917131^). + +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 WORKFLOW = path.join(ROOT, 'gsd-core', 'workflows', 'add-backlog.md'); +const COMMANDS_DIR = path.join(ROOT, 'commands', 'gsd'); + +// ─── #3135: add-backlog workflow ───────────────────────────────────────────── + +describe('#3135: gsd-core/workflows/add-backlog.md', () => { + test('file exists', () => { + assert.ok( + fs.existsSync(WORKFLOW), + 'gsd-core/workflows/add-backlog.md does not exist — capture --backlog has no implementation to load', + ); + }); + + test('uses gsd-sdk query phase.next-decimal to find next 999.x slot', () => { + const src = fs.readFileSync(WORKFLOW, 'utf8'); + assert.ok( + src.includes('phase.next-decimal'), + 'add-backlog.md must use gsd-sdk query phase.next-decimal to find the next 999.x number', + ); + }); + + test('writes to ROADMAP.md', () => { + const src = fs.readFileSync(WORKFLOW, 'utf8'); + assert.ok(src.includes('ROADMAP.md'), 'add-backlog.md must write to ROADMAP.md'); + }); + + test('creates a .planning/phases/ directory', () => { + const src = fs.readFileSync(WORKFLOW, 'utf8'); + assert.ok( + src.includes('.planning/phases') || src.includes('planning/phases'), + 'add-backlog.md must create a phase directory under .planning/phases/', + ); + }); + + test('uses generate-slug for the directory name', () => { + const src = fs.readFileSync(WORKFLOW, 'utf8'); + assert.ok( + src.includes('generate-slug'), + 'add-backlog.md must use gsd-sdk query generate-slug to build the phase directory slug', + ); + }); + + test('commits via gsd-sdk query commit', () => { + const src = fs.readFileSync(WORKFLOW, 'utf8'); + assert.ok( + src.includes('gsd-sdk query commit') || src.includes('query commit'), + 'add-backlog.md must commit via gsd-sdk query commit', + ); + }); + + test('writes ROADMAP entry before creating directory (#2280 ordering invariant)', () => { + const src = fs.readFileSync(WORKFLOW, 'utf8'); + const roadmapIdx = src.indexOf('ROADMAP.md'); + const mkdirIdx = src.search(/mkdir|\.gitkeep/); + assert.ok(roadmapIdx !== -1, 'ROADMAP.md write step not found'); + assert.ok(mkdirIdx !== -1, 'directory creation step not found'); + assert.ok( + roadmapIdx < mkdirIdx, + 'ROADMAP.md entry must be written BEFORE the phase directory is created (#2280 ordering invariant)', + ); + }); + + test('uses 999.x numbering for backlog items', () => { + const src = fs.readFileSync(WORKFLOW, 'utf8'); + assert.ok( + src.includes('999'), + 'add-backlog.md must document 999.x numbering scheme for backlog items', + ); + }); + + test('documents /gsd-review-backlog for promotion', () => { + const src = fs.readFileSync(WORKFLOW, 'utf8'); + assert.ok( + src.includes('review-backlog') || src.includes('gsd-review-backlog'), + 'add-backlog.md should mention /gsd-review-backlog for promoting items to active milestone', + ); + }); +}); + +// ─── capture.md routing integrity ──────────────────────────────────────────── + +describe('#3135: capture.md correctly routes --backlog to add-backlog workflow', () => { + function executionContextIncludes(body) { + const blocks = [ + ...body.matchAll(/([\s\S]*?)<\/execution_context(?:_extended)?>/g), + ].map((m) => m[1]); + const targets = []; + for (const blk of blocks) { + for (const line of blk.split('\n')) { + const t = line.trim(); + if (!t.startsWith('@')) continue; + const rel = t.replace(/^@~?\/?(?:\.claude\/)?(?:gsd-core\/)?/, ''); + targets.push(rel); + } + } + return targets; + } + + test('capture.md execution_context @-includes add-backlog.md', () => { + const body = fs.readFileSync(path.join(COMMANDS_DIR, 'capture.md'), 'utf8'); + const targets = executionContextIncludes(body); + assert.ok( + targets.some((t) => /(^|\/)workflows\/add-backlog\.md$/.test(t)), + `capture.md execution_context must @-include workflows/add-backlog.md; got: ${JSON.stringify(targets)}`, + ); + }); +}); + + }); +} diff --git a/tests/enh-1055-config-intent-descriptor-drive.test.cjs b/tests/enh-1055-config-intent-descriptor-drive.test.cjs deleted file mode 100644 index 3ac1b72a6..000000000 --- a/tests/enh-1055-config-intent-descriptor-drive.test.cjs +++ /dev/null @@ -1,134 +0,0 @@ -'use strict'; - -/** - * ADR-857 phase 5g drive 2: resolveRuntimeConfigIntent is now driven by the - * runtime capability descriptor (capability-registry.cjs) rather than a - * hand-kept REGISTRY const. - * - * This golden-master test pins the observable contract: the return shape and - * values must be identical to the pre-change behavior for all 16 runtimes. - * Purely behavioral — no source-grep. - */ - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const path = require('node:path'); - -const ROOT = path.join(__dirname, '..'); -const { - resolveRuntimeConfigIntent, - ALLOWED_CONFIG_RUNTIMES, -} = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-config-adapter-registry.cjs')); - -// --------------------------------------------------------------------------- -// Frozen expected table (pre-change behavior — the contract being pinned) -// --------------------------------------------------------------------------- - -const EXPECTED = [ - { runtime: 'claude', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null }, - { runtime: 'gemini', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null }, - { runtime: 'antigravity', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null }, - { runtime: 'augment', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null }, - { runtime: 'qwen', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null }, - { runtime: 'hermes', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null }, - { runtime: 'codebuddy', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null }, - { runtime: 'opencode', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: 'opencode' }, - { runtime: 'kilo', installSurface: 'settings-json', writesSharedSettings: false, finishPermissionWriter: 'kilo' }, - { runtime: 'codex', installSurface: 'codex-toml', writesSharedSettings: false, finishPermissionWriter: null }, - { runtime: 'copilot', installSurface: 'copilot-instructions', writesSharedSettings: false, finishPermissionWriter: null }, - { runtime: 'cline', installSurface: 'cline-rules', writesSharedSettings: false, finishPermissionWriter: null }, - { runtime: 'cursor', installSurface: 'cursor-hooks-json', writesSharedSettings: false, finishPermissionWriter: null }, - { runtime: 'windsurf', installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null }, - { runtime: 'trae', installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null }, - { runtime: 'kimi', installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null }, -]; - -// --------------------------------------------------------------------------- -// Test 1: Golden master — all 16 runtimes resolve to expected values -// --------------------------------------------------------------------------- - -describe('enh-1055 descriptor-drive: resolveRuntimeConfigIntent golden master', () => { - for (const row of EXPECTED) { - test(`${row.runtime} resolves to expected intent`, () => { - const intent = resolveRuntimeConfigIntent(row.runtime); - assert.deepStrictEqual(intent, { - runtime: row.runtime, - installSurface: row.installSurface, - writesSharedSettings: row.writesSharedSettings, - finishPermissionWriter: row.finishPermissionWriter, - }); - }); - } -}); - -// --------------------------------------------------------------------------- -// Test 3: Unknown runtime throws TypeError -// --------------------------------------------------------------------------- - -describe('enh-1055 descriptor-drive: unknown runtime throws TypeError', () => { - test('throws TypeError for "bogus-runtime"', () => { - assert.throws(() => resolveRuntimeConfigIntent('bogus-runtime'), TypeError); - }); - - test('throws TypeError for empty string', () => { - assert.throws(() => resolveRuntimeConfigIntent(''), TypeError); - }); - - test('throws TypeError for undefined', () => { - assert.throws(() => resolveRuntimeConfigIntent(undefined), TypeError); - }); - - test('throws TypeError for "__proto__"', () => { - assert.throws(() => resolveRuntimeConfigIntent('__proto__'), TypeError); - }); -}); - -// --------------------------------------------------------------------------- -// Test 4: ALLOWED_CONFIG_RUNTIMES contains all 16 expected runtimes -// --------------------------------------------------------------------------- - -describe('enh-1055 descriptor-drive: ALLOWED_CONFIG_RUNTIMES completeness', () => { - const EXPECTED_16 = new Set([ - 'claude', 'gemini', 'antigravity', 'augment', 'qwen', 'hermes', 'codebuddy', - 'opencode', 'kilo', 'codex', 'copilot', 'cline', 'cursor', 'windsurf', 'trae', 'kimi', - ]); - - test('contains exactly the 16 expected runtimes', () => { - assert.deepStrictEqual(new Set(ALLOWED_CONFIG_RUNTIMES), EXPECTED_16); - }); - - test('has exactly 16 entries', () => { - assert.strictEqual([...ALLOWED_CONFIG_RUNTIMES].length, 16); - }); -}); - -// --------------------------------------------------------------------------- -// Test 5: Descriptor drive — the function reads from the descriptor, not -// a hardcoded local constant. This is proven indirectly: the golden master -// passes, meaning capability-registry.cjs (the live descriptor) matches the -// expected table. If the adapter had its own REGISTRY, a descriptor change -// would diverge silently; with drive, it cannot. -// --------------------------------------------------------------------------- - -describe('enh-1055 descriptor-drive: finishPermissionWriter passthrough', () => { - test('opencode → "opencode" (descriptor permissionWriter)', () => { - assert.strictEqual(resolveRuntimeConfigIntent('opencode').finishPermissionWriter, 'opencode'); - }); - - test('kilo → "kilo" (descriptor permissionWriter)', () => { - assert.strictEqual(resolveRuntimeConfigIntent('kilo').finishPermissionWriter, 'kilo'); - }); - - test('all other runtimes have finishPermissionWriter === null', () => { - const nullExpected = EXPECTED - .filter(r => r.finishPermissionWriter === null) - .map(r => r.runtime); - for (const runtime of nullExpected) { - assert.strictEqual( - resolveRuntimeConfigIntent(runtime).finishPermissionWriter, - null, - `${runtime} should have finishPermissionWriter null`, - ); - } - }); -}); diff --git a/tests/enh-1082-install-plan-capstone.test.cjs b/tests/enh-1082-install-plan-capstone.test.cjs deleted file mode 100644 index 457b27673..000000000 --- a/tests/enh-1082-install-plan-capstone.test.cjs +++ /dev/null @@ -1,238 +0,0 @@ -'use strict'; - -/** - * Golden-master test for resolveInstallPlan — ADR-857 phase 5g capstone. - * - * Pins the exact InstallPlan shape for all 16 runtimes to guard against - * descriptor drift. Derived from actual resolveInstallPlan output at the time - * the seam was introduced (2026-06-11). Behavioral: calls the exported - * function and asserts on typed fields — no source-grep. - */ - -const { describe, it } = require('node:test'); -const assert = require('node:assert/strict'); -const { resolveInstallPlan } = require('../gsd-core/bin/lib/runtime-config-adapter-registry.cjs'); - -// --------------------------------------------------------------------------- -// Frozen golden-master table — derived from actual resolveInstallPlan output -// --------------------------------------------------------------------------- - -const EXPECTED = { - claude: { - runtime: 'claude', - installSurface: 'settings-json', - writesSharedSettings: true, - finishPermissionWriter: null, - hookEvents: 'claude', - extendedHookEvents: ['SubagentStop', 'Stop', 'PreCompact', 'FileChanged'], - hooksSurface: 'settings-json', - sandboxTier: 'none', - }, - codex: { - runtime: 'codex', - installSurface: 'codex-toml', - writesSharedSettings: false, - finishPermissionWriter: null, - hookEvents: 'claude', - extendedHookEvents: [], - hooksSurface: 'codex-hooks-json', - sandboxTier: 'codex-agent-sandbox', - }, - antigravity: { - runtime: 'antigravity', - installSurface: 'settings-json', - writesSharedSettings: true, - finishPermissionWriter: null, - hookEvents: 'gemini', - extendedHookEvents: [], - hooksSurface: 'settings-json', - sandboxTier: 'none', - }, - gemini: { - runtime: 'gemini', - installSurface: 'settings-json', - writesSharedSettings: true, - finishPermissionWriter: null, - hookEvents: 'gemini', - extendedHookEvents: ['BeforeAgent', 'AfterAgent', 'BeforeModel'], - hooksSurface: 'settings-json', - sandboxTier: 'none', - }, - cursor: { - runtime: 'cursor', - installSurface: 'cursor-hooks-json', - writesSharedSettings: false, - finishPermissionWriter: null, - hookEvents: 'claude', - extendedHookEvents: [], - hooksSurface: 'cursor-hooks-json', - sandboxTier: 'none', - }, - opencode: { - runtime: 'opencode', - installSurface: 'settings-json', - writesSharedSettings: true, - finishPermissionWriter: 'opencode', - hookEvents: undefined, - extendedHookEvents: [], - hooksSurface: 'none', - sandboxTier: 'none', - }, - kilo: { - runtime: 'kilo', - installSurface: 'settings-json', - writesSharedSettings: false, - finishPermissionWriter: 'kilo', - hookEvents: undefined, - extendedHookEvents: [], - hooksSurface: 'none', - sandboxTier: 'none', - }, - copilot: { - runtime: 'copilot', - installSurface: 'copilot-instructions', - writesSharedSettings: false, - finishPermissionWriter: null, - hookEvents: undefined, - extendedHookEvents: [], - hooksSurface: 'copilot-inline', - sandboxTier: 'none', - }, - augment: { - runtime: 'augment', - installSurface: 'settings-json', - writesSharedSettings: true, - finishPermissionWriter: null, - hookEvents: 'claude', - extendedHookEvents: [], - hooksSurface: 'settings-json', - sandboxTier: 'none', - }, - trae: { - runtime: 'trae', - installSurface: 'profile-marker-only', - writesSharedSettings: false, - finishPermissionWriter: null, - hookEvents: undefined, - extendedHookEvents: [], - hooksSurface: 'none', - sandboxTier: 'none', - }, - qwen: { - runtime: 'qwen', - installSurface: 'settings-json', - writesSharedSettings: true, - finishPermissionWriter: null, - hookEvents: 'claude', - extendedHookEvents: ['SubagentStop', 'Stop', 'PreCompact'], - hooksSurface: 'settings-json', - sandboxTier: 'none', - }, - hermes: { - runtime: 'hermes', - installSurface: 'settings-json', - writesSharedSettings: true, - finishPermissionWriter: null, - hookEvents: 'claude', - extendedHookEvents: [], - hooksSurface: 'settings-json', - sandboxTier: 'none', - }, - codebuddy: { - runtime: 'codebuddy', - installSurface: 'settings-json', - writesSharedSettings: true, - finishPermissionWriter: null, - hookEvents: 'claude', - extendedHookEvents: [], - hooksSurface: 'settings-json', - sandboxTier: 'none', - }, - cline: { - runtime: 'cline', - installSurface: 'cline-rules', - writesSharedSettings: false, - finishPermissionWriter: null, - hookEvents: undefined, - extendedHookEvents: [], - hooksSurface: 'cline-rules', - sandboxTier: 'none', - }, - kimi: { - runtime: 'kimi', - installSurface: 'profile-marker-only', - writesSharedSettings: false, - finishPermissionWriter: null, - hookEvents: undefined, - extendedHookEvents: [], - hooksSurface: 'none', - sandboxTier: 'none', - }, - windsurf: { - runtime: 'windsurf', - installSurface: 'profile-marker-only', - writesSharedSettings: false, - finishPermissionWriter: null, - hookEvents: undefined, - extendedHookEvents: [], - hooksSurface: 'none', - sandboxTier: 'none', - }, -}; - -const ALL_RUNTIMES = Object.keys(EXPECTED); - -describe('resolveInstallPlan — ADR-857 phase 5g golden master', () => { - it('covers exactly 16 runtimes', () => { - assert.strictEqual(ALL_RUNTIMES.length, 16); - }); - - for (const runtime of ALL_RUNTIMES) { - it(`resolveInstallPlan('${runtime}') matches frozen plan`, () => { - const actual = resolveInstallPlan(runtime); - assert.deepStrictEqual(actual, EXPECTED[runtime], - `InstallPlan for '${runtime}' drifted from golden master`); - }); - } - - it('resolveInstallPlan throws TypeError for unknown runtime', () => { - assert.throws( - () => resolveInstallPlan('bogus'), - (err) => err instanceof TypeError && /bogus/.test(err.message), - ); - }); - - it('extendedHookEvents is always an array for every runtime', () => { - for (const runtime of ALL_RUNTIMES) { - const plan = resolveInstallPlan(runtime); - assert.ok(Array.isArray(plan.extendedHookEvents), - `${runtime}: extendedHookEvents should be an array`); - } - }); - - it('hooksSurface is always a non-empty string for every runtime', () => { - for (const runtime of ALL_RUNTIMES) { - const plan = resolveInstallPlan(runtime); - assert.strictEqual(typeof plan.hooksSurface, 'string', - `${runtime}: hooksSurface should be a string`); - assert.ok(plan.hooksSurface.length > 0, - `${runtime}: hooksSurface should not be empty`); - } - }); - - it('parity: resolveInstallPlan config-intent fields match resolveRuntimeConfigIntent', () => { - // Guard that resolveInstallPlan composes resolveRuntimeConfigIntent correctly — - // any drift between the two would silently break install(). - const { resolveRuntimeConfigIntent } = require('../gsd-core/bin/lib/runtime-config-adapter-registry.cjs'); - for (const runtime of ALL_RUNTIMES) { - const plan = resolveInstallPlan(runtime); - const intent = resolveRuntimeConfigIntent(runtime); - assert.strictEqual(plan.installSurface, intent.installSurface, - `${runtime}: installSurface mismatch between plan and intent`); - assert.strictEqual(plan.writesSharedSettings, intent.writesSharedSettings, - `${runtime}: writesSharedSettings mismatch`); - assert.strictEqual(plan.finishPermissionWriter, intent.finishPermissionWriter, - `${runtime}: finishPermissionWriter mismatch`); - } - }); -}); diff --git a/tests/enh-1494-workflow-config-key-docs.test.cjs b/tests/enh-1494-workflow-config-key-docs.test.cjs deleted file mode 100644 index bd782533d..000000000 --- a/tests/enh-1494-workflow-config-key-docs.test.cjs +++ /dev/null @@ -1,118 +0,0 @@ -'use strict'; - -/** - * Parity assertions for #1494: workflow config keys that are consumed by - * planning-pipeline code must be (a) accepted by VALID_CONFIG_KEYS and - * (b) documented in references/planning-config.md. - * - * Per DEFECT.GENERATIVE-FIX: a shared constant / key-list that spans two - * surfaces requires a parity assertion that fails when the surfaces diverge. - */ - -const { describe, test, before, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs'); - -const CONFIG_SCHEMA_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'config-schema.cjs'); -const PLANNING_CONFIG_PATH = path.join(__dirname, '..', 'gsd-core', 'references', 'planning-config.md'); - -describe('VALID_CONFIG_KEYS parity — #1494 orphan-undocumented keys', () => { - const { VALID_CONFIG_KEYS } = require(CONFIG_SCHEMA_PATH); - - test('workflow.mvp_mode is in VALID_CONFIG_KEYS', () => { - assert.ok( - VALID_CONFIG_KEYS.has('workflow.mvp_mode'), - 'workflow.mvp_mode is read by config-loader.cts and plan-phase.md but was missing from VALID_CONFIG_KEYS (#1494)' - ); - }); - - test('workflow.code_review_command is in VALID_CONFIG_KEYS', () => { - assert.ok( - VALID_CONFIG_KEYS.has('workflow.code_review_command'), - 'workflow.code_review_command must be in VALID_CONFIG_KEYS' - ); - }); - - test('workflow.plan_chunked is in VALID_CONFIG_KEYS', () => { - assert.ok( - VALID_CONFIG_KEYS.has('workflow.plan_chunked'), - 'workflow.plan_chunked must be in VALID_CONFIG_KEYS' - ); - }); - - test('workflow.test_command is in VALID_CONFIG_KEYS', () => { - assert.ok( - VALID_CONFIG_KEYS.has('workflow.test_command'), - 'workflow.test_command must be in VALID_CONFIG_KEYS' - ); - }); - - test('workflow.build_command is in VALID_CONFIG_KEYS', () => { - assert.ok( - VALID_CONFIG_KEYS.has('workflow.build_command'), - 'workflow.build_command must be in VALID_CONFIG_KEYS' - ); - }); -}); - -describe('config-set accepts workflow.mvp_mode (#1494)', () => { - let tmpDir; - afterEach(() => { if (tmpDir) cleanup(tmpDir); }); - - test('config-set workflow.mvp_mode true succeeds and stores the value', () => { - tmpDir = createTempProject(); - const result = runGsdTools(['config-set', 'workflow.mvp_mode', 'true'], tmpDir); - assert.ok( - result.success, - `config-set workflow.mvp_mode must succeed; got:\nstdout: ${result.output}\nstderr: ${result.error}` - ); - const parsed = JSON.parse(result.output); - assert.strictEqual(parsed.updated, true, 'response must have updated:true'); - assert.strictEqual(parsed.key, 'workflow.mvp_mode', 'response must echo the key'); - }); - - test('config-set workflow.mvp_mode false succeeds', () => { - tmpDir = createTempProject(); - const result = runGsdTools(['config-set', 'workflow.mvp_mode', 'false'], tmpDir); - assert.ok( - result.success, - `config-set workflow.mvp_mode false must succeed; got:\nstdout: ${result.output}\nstderr: ${result.error}` - ); - const parsed = JSON.parse(result.output); - assert.strictEqual(parsed.updated, true); - }); -}); - -// allow-test-rule: source-text-is-the-product — planning-config.md is the deployed reference contract (#1494) -describe('planning-config.md documents #1494 keys', () => { - let content; - before(() => { content = fs.readFileSync(PLANNING_CONFIG_PATH, 'utf-8'); }); - - const KEYS = [ - 'workflow.mvp_mode', - 'workflow.code_review_command', - 'workflow.plan_chunked', - 'workflow.test_command', - 'workflow.build_command', - ]; - - for (const key of KEYS) { - test(`planning-config.md documents \`${key}\``, () => { - assert.ok( - content.includes(`\`${key}\``), - `planning-config.md must document \`${key}\` (#1494)` - ); - }); - - test(`\`${key}\` appears in the Complete Field Reference section`, () => { - const refSection = content.slice(content.indexOf('## Complete Field Reference')); - assert.ok( - refSection.includes(`\`${key}\``), - `planning-config.md Complete Field Reference must include \`${key}\` (#1494)` - ); - }); - } -}); diff --git a/tests/enh-1676-path-prefix-collapse-idempotency.property.test.cjs b/tests/enh-1676-path-prefix-collapse-idempotency.property.test.cjs deleted file mode 100644 index 76af6d54c..000000000 --- a/tests/enh-1676-path-prefix-collapse-idempotency.property.test.cjs +++ /dev/null @@ -1,182 +0,0 @@ -'use strict'; - -/** - * Property-based tests for the relocated rewrite engine (ADR-1508 Phase 2). - * - * Module: gsd-core/bin/lib/runtime-artifact-conversion.cjs - * Exports under test: - * - _computePathPrefix (private; the path-prefix derivation owner) - * - _applyRuntimeRewrites (the per-runtime content-rewrite engine) - * - * Issue #1676 (epic #1507 / ADR-1508): delivers the fast-check property - * coverage promised in #1511's test scope but not landed there. - * - * Properties under test: - * (A) $HOME-collapse invariant — a global install whose target lives under - * $HOME (and is not opencode) MUST project to a `$HOME//` - * prefix, never the resolved absolute homedir path. opencode is the - * documented exception (it uses ~/.config/opencode, which breaks the - * $HOME shorthand inside double-quoted content). Asserted by EXACT - * equality (not substring) so short homes like `/root` or `/a` do not - * false-positive — the same trap handled at - * tests/path-replacement.test.cjs:38-47. - * (B) backslash→posix invariance (#1615 Windows path-leak fix): feeding - * Windows backslash paths yields the same prefix as their posix form. - * (C) path-rewrite idempotency — applying the engine twice yields the same - * bytes as once (no double-prefixing, no `$HOME`-of-`$HOME`). - * attribution is held at undefined to isolate the path-rewrite axis. - */ - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const path = require('node:path'); -const fc = require('./helpers/fast-check-setup.cjs'); - -const conversion = require( - path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'runtime-artifact-conversion.cjs'), -); -const { getDirName } = require( - path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'runtime-name-policy.cjs'), -); - -// Runtimes whose _applyRuntimeRewrites branch performs real path rewrites -// (~/.claude/ → pathPrefix). copilot/antigravity only do attribution; claude -// and the gemini/kilo family are omitted to keep the property on the path axis -// the issue names. getDirName(`.${rt}`) == `.${rt}` for every entry here. -const PATH_REWRITE_RUNTIMES = ['codex', 'cline', 'cursor', 'windsurf', 'augment', 'trae', 'codebuddy']; - -const HOMES = ['/home/u', '/Users/x', '/root', '/srv/app', '/a']; -const SEG = fc.stringMatching(/^[a-z][a-z0-9]{0,8}$/); - -// Build a posix `home/` target with no leading/trailing/double slashes. -const target = (home, segs) => `${home}/${segs.join('/')}`; - -describe('_computePathPrefix: $HOME-collapse invariant (#1676 / ADR-1508)', () => { - test('property: global-under-home collapses to $HOME// unless opencode', () => { - fc.assert( - fc.property( - fc.record({ - home: fc.constantFrom(...HOMES), - segs: fc.array(SEG, { minLength: 1, maxLength: 3 }), - isOpencode: fc.boolean(), - }), - ({ home, segs, isOpencode }) => { - const resolvedTarget = target(home, segs); - const suffix = segs.join('/'); - const prefix = conversion._computePathPrefix({ - isGlobal: true, - isOpencode, - isWindowsHost: false, - resolvedTarget, - homeDir: home, - }); - if (!isOpencode) { - // Collapse: exact $HOME form, never the resolved homedir path. - assert.equal(prefix, `$HOME/${suffix}/`); - assert.ok(prefix.startsWith('$HOME/'), 'prefix must start with $HOME token'); - } else { - // opencode: absolute resolved form, never the $HOME shorthand. - assert.equal(prefix, `${resolvedTarget}/`); - assert.ok(!prefix.startsWith('$HOME'), 'opencode must not use $HOME shorthand'); - } - }, - ), - ); - }); - - test('property: non-global never collapses — always the resolved absolute form', () => { - fc.assert( - fc.property( - fc.record({ - home: fc.constantFrom(...HOMES), - segs: fc.array(SEG, { minLength: 1, maxLength: 3 }), - }), - ({ home, segs }) => { - const resolvedTarget = target(home, segs); - const prefix = conversion._computePathPrefix({ - isGlobal: false, - isOpencode: false, - isWindowsHost: false, - resolvedTarget, - homeDir: home, - }); - assert.equal(prefix, `${resolvedTarget}/`); - assert.ok(!prefix.startsWith('$HOME')); - }, - ), - ); - }); - - test('property: backslash paths project identically to their posix form (#1615)', () => { - fc.assert( - fc.property( - fc.record({ - home: fc.constantFrom(...HOMES), - segs: fc.array(SEG, { minLength: 1, maxLength: 3 }), - }), - ({ home, segs }) => { - const posixTarget = target(home, segs); - const backslashTarget = posixTarget.replace(/\//g, '\\'); - const backslashHome = home.replace(/\//g, '\\'); - const fromBackslash = conversion._computePathPrefix({ - isGlobal: true, - isOpencode: false, - isWindowsHost: false, - resolvedTarget: backslashTarget, - homeDir: backslashHome, - }); - const fromPosix = conversion._computePathPrefix({ - isGlobal: true, - isOpencode: false, - isWindowsHost: false, - resolvedTarget: posixTarget, - homeDir: home, - }); - assert.equal(fromBackslash, fromPosix); - }, - ), - ); - }); -}); - -describe('_applyRuntimeRewrites: path-rewrite idempotency (#1676 / ADR-1508)', () => { - test('property: f(f(content)) === f(content) across path-rewriting runtimes', () => { - fc.assert( - fc.property( - fc.record({ - runtime: fc.constantFrom(...PATH_REWRITE_RUNTIMES), - home: fc.constantFrom(...HOMES), - isGlobal: fc.boolean(), - segs: fc.array(SEG, { minLength: 1, maxLength: 4 }), - }), - ({ runtime, home, isGlobal, segs }) => { - // configDir under $HOME so global collapses to $HOME/./; - // non-global projects to an absolute form. Both are prefix shapes - // that contain no matchable ~/.claude or $HOME/.claude token, so a - // second rewrite pass is a no-op. - const dirName = getDirName(runtime).replace(/^\./, ''); - const configDir = target(home, [`.${dirName}`]); - const pathPrefix = conversion._computePathPrefix({ - isGlobal, - isOpencode: false, - isWindowsHost: false, - resolvedTarget: configDir, - homeDir: home, - }); - - // Seed content with every reference shape the engine rewrites, - // interleaved with inert prose so rewrites land mid-line. - const lines = segs.map((s) => - `See ~/.claude/skills/${s} or $HOME/.claude/agents/${s}; also ./.claude/${s}.`); - const content = lines.join('\n'); - - const once = conversion._applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal, undefined); - const twice = conversion._applyRuntimeRewrites(once, runtime, pathPrefix, isGlobal, undefined); - assert.equal(twice, once, 'second rewrite pass must be a no-op (idempotent)'); - // Sanity: the seed references were actually rewritten on the first pass. - assert.ok(!once.includes('~/.claude/'), 'first pass must eliminate ~/.claude/ refs'); - }, - ), - ); - }); -}); diff --git a/tests/enh-2310-chunked-plan-phase.test.cjs b/tests/enh-2310-chunked-plan-phase.test.cjs deleted file mode 100644 index 67fc71043..000000000 --- a/tests/enh-2310-chunked-plan-phase.test.cjs +++ /dev/null @@ -1,241 +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'; - - -/** - * Tests for #2310: plan-phase chunked mode + filesystem fallback. - * - * Context: on Windows (and occasionally other platforms), gsd-planner's - * Task() call may never return even though the subagent finished writing all - * PLAN.md files to disk. The orchestrator hangs indefinitely. Two mitigations: - * - * 1. Filesystem fallback (steps 9a, 11a): if the Task() return lacks the - * expected marker but PLAN.md files exist on disk, surface a recoverable - * prompt instead of hanging/failing silently. - * - * 2. Chunked mode (step 8.5): --chunked flag / workflow.plan_chunked config - * splits the single long planner Task into (a) a short outline Task and - * (b) N short single-plan Tasks. Each Task is shorter-lived, the - * orchestrator can commit work incrementally, and a hang loses only one - * plan instead of the entire phase. - */ - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const PLAN_PHASE = path.join( - __dirname, '..', 'gsd-core', 'workflows', 'plan-phase.md' -); - -const PLANNER_AGENT = path.join(__dirname, '..', 'agents', 'gsd-planner.md'); -const PLANNER_CHUNKED_REF = path.join(__dirname, '..', 'gsd-core', 'references', 'planner-chunked.md'); -const CONFIG_SCHEMA = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'config-schema.cjs'); -const CONFIGURATION_MD = path.join(__dirname, '..', 'docs', 'CONFIGURATION.md'); - -describe('plan-phase.md — filesystem fallback (#2310)', () => { - const content = fs.readFileSync(PLAN_PHASE, 'utf-8'); - - test('step 9 checks PLAN.md count on disk when planner return lacks completion marker', () => { - assert.ok( - content.includes('DISK_PLANS=$(ls "${PHASE_DIR}"/*-PLAN.md'), - 'step 9a must check disk for PLAN.md files via DISK_PLANS variable' - ); - }); - - test('step 9a fallback section exists', () => { - assert.ok( - content.includes('## 9a. Filesystem Fallback'), - 'plan-phase.md must have a ## 9a. Filesystem Fallback section for planner hang recovery' - ); - }); - - test('step 9a fallback offers Accept plans option', () => { - assert.ok( - content.includes('Accept plans'), - 'step 9a must offer "Accept plans" as a recovery option' - ); - }); - - test('step 9a fallback offers Retry planner option', () => { - assert.ok( - content.includes('Retry planner'), - 'step 9a must offer "Retry planner" as a recovery option' - ); - }); - - test('step 11 has filesystem fallback section', () => { - assert.ok( - content.includes('## 11a. Filesystem Fallback'), - 'plan-phase.md must have a ## 11a. Filesystem Fallback section for checker hang recovery' - ); - }); - - test('step 11a fallback offers Accept verification option', () => { - assert.ok( - content.includes('Accept verification'), - 'step 11a must offer "Accept verification" as a recovery option' - ); - }); - - test('step 11a fallback offers Retry checker option', () => { - assert.ok( - content.includes('Retry checker'), - 'step 11a must offer "Retry checker" as a recovery option' - ); - }); - - test('step 9 routes to step 9a when no recognized marker found', () => { - assert.ok( - content.includes('step 9a') || content.includes('9a.'), - 'step 9 handle-return must reference the filesystem fallback path (step 9a)' - ); - }); - - test('step 11 routes to step 11a when no recognized marker found', () => { - assert.ok( - content.includes('step 11a') || content.includes('11a.'), - 'step 11 handle-return must reference the filesystem fallback path (step 11a)' - ); - }); -}); - -describe('plan-phase.md — chunked mode flag and config (#2310)', () => { - const content = fs.readFileSync(PLAN_PHASE, 'utf-8'); - - test('step 2 parses --chunked flag', () => { - assert.ok( - content.includes('--chunked'), - 'step 2 must parse --chunked flag from $ARGUMENTS' - ); - }); - - test('step 2 reads workflow.plan_chunked config', () => { - assert.ok( - content.includes('workflow.plan_chunked'), - 'step 2 must read workflow.plan_chunked config key' - ); - }); - - test('step 2 sets CHUNKED_MODE variable', () => { - assert.ok( - content.includes('CHUNKED_MODE'), - 'step 2 must set CHUNKED_MODE from flag or config' - ); - }); -}); - -describe('plan-phase.md — chunked mode implementation (#2310)', () => { - const content = fs.readFileSync(PLAN_PHASE, 'utf-8'); - - test('step 8.5 chunked planning section exists', () => { - assert.ok( - content.includes('## 8.5.'), - 'plan-phase.md must have a step 8.5 section for chunked planning mode' - ); - }); - - test('chunked mode produces PLAN-OUTLINE.md', () => { - assert.ok( - content.includes('PLAN-OUTLINE.md'), - 'chunked mode outline step must produce a *-PLAN-OUTLINE.md file' - ); - }); - - test('chunked outline step uses outline-only mode', () => { - assert.ok( - content.includes('outline-only'), - 'chunked step 8.5.1 must spawn the planner in outline-only mode' - ); - }); - - test('chunked per-plan step uses single-plan mode', () => { - assert.ok( - content.includes('single-plan'), - 'chunked step 8.5.2 must spawn the planner in single-plan mode for each plan' - ); - }); - - test('chunked mode checks for existing outline to enable resume', () => { - // The resume check skips the outline Task if PLAN-OUTLINE.md already exists - assert.ok( - content.includes('PLAN-OUTLINE.md') && content.includes('already exists'), - 'chunked mode must detect existing PLAN-OUTLINE.md and skip outline generation (resume safety)' - ); - }); - - test('chunked mode commits each plan individually', () => { - assert.ok( - content.includes('chunked'), - 'chunked mode must commit each individual plan for crash resilience' - ); - }); - - test('step 8 routes to chunked path when CHUNKED_MODE is true', () => { - assert.ok( - content.includes('CHUNKED_MODE') && content.includes('8.5'), - 'step 8 must route to step 8.5 when CHUNKED_MODE is true' - ); - }); -}); - -describe('gsd-planner.md — references planner-chunked.md (#2310)', () => { - const plannerContent = fs.readFileSync(PLANNER_AGENT, 'utf-8'); - - test('gsd-planner.md references planner-chunked.md for chunked return formats', () => { - assert.ok( - plannerContent.includes('planner-chunked.md'), - 'gsd-planner.md must reference planner-chunked.md for ## OUTLINE COMPLETE / ## PLAN COMPLETE formats' - ); - }); -}); - -describe('planner-chunked.md — chunked return formats (#2310)', () => { - const content = fs.readFileSync(PLANNER_CHUNKED_REF, 'utf-8'); - - test('planner-chunked.md defines OUTLINE COMPLETE structured return', () => { - assert.ok( - content.includes('## OUTLINE COMPLETE'), - 'planner-chunked.md must define ## OUTLINE COMPLETE return format for outline-only mode' - ); - }); - - test('planner-chunked.md defines PLAN COMPLETE structured return for single-plan mode', () => { - assert.ok( - content.includes('## PLAN COMPLETE'), - 'planner-chunked.md must define ## PLAN COMPLETE return format for single-plan mode' - ); - }); - - test('planner-chunked.md describes resume behaviour', () => { - assert.ok( - content.includes('Resume') || content.includes('resume'), - 'planner-chunked.md must describe resume behaviour for interrupted chunked runs' - ); - }); -}); - -describe('config-schema.cjs — workflow.plan_chunked key (#2310)', () => { - test('VALID_CONFIG_KEYS includes workflow.plan_chunked', () => { - const { VALID_CONFIG_KEYS } = require(CONFIG_SCHEMA); - assert.ok( - VALID_CONFIG_KEYS.has('workflow.plan_chunked'), - 'config-schema.cjs VALID_CONFIG_KEYS must include workflow.plan_chunked' - ); - }); -}); - -describe('docs/CONFIGURATION.md — workflow.plan_chunked documented (#2310)', () => { - const content = fs.readFileSync(CONFIGURATION_MD, 'utf-8'); - - test('CONFIGURATION.md documents workflow.plan_chunked', () => { - assert.ok( - content.includes('`workflow.plan_chunked`'), - 'docs/CONFIGURATION.md must document workflow.plan_chunked' - ); - }); -}); diff --git a/tests/enh-2415-claude-md-link-mode.test.cjs b/tests/enh-2415-claude-md-link-mode.test.cjs deleted file mode 100644 index 4f293c3e3..000000000 --- a/tests/enh-2415-claude-md-link-mode.test.cjs +++ /dev/null @@ -1,113 +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 claude_md_assembly "link" mode (#2415). - * Verifies that generate-claude-md writes @-references instead of inlined - * content when claude_md_assembly.mode is "link". - */ - -const { test, after } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const os = require('node:os'); -const helpers = require('./helpers.cjs'); - -const { cmdGenerateClaudeMd } = require('../gsd-core/bin/lib/profile-output.cjs'); - -const _dirsToClean = []; -after(() => { for (const d of _dirsToClean) helpers.cleanup(d); }); - -function makeTempProject(files = {}) { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2415-')); - _dirsToClean.push(dir); - fs.mkdirSync(path.join(dir, '.planning', 'codebase'), { recursive: true }); - for (const [rel, content] of Object.entries(files)) { - const abs = path.join(dir, rel); - fs.mkdirSync(path.dirname(abs), { recursive: true }); - fs.writeFileSync(abs, content, 'utf-8'); - } - return dir; -} - -test('link mode writes @-reference for architecture section', () => { - const dir = makeTempProject({ - '.planning/codebase/ARCHITECTURE.md': '# Architecture\n\n- layered\n', - '.planning/config.json': JSON.stringify({ claude_md_assembly: { mode: 'link' } }), - }); - - cmdGenerateClaudeMd(dir, { output: path.join(dir, 'CLAUDE.md') }, false); - - const content = fs.readFileSync(path.join(dir, 'CLAUDE.md'), 'utf-8'); - assert.ok(content.includes('@.planning/codebase/ARCHITECTURE.md'), 'should contain @-reference'); - assert.ok(!content.includes('- layered'), 'should not inline architecture content'); -}); - -test('link mode writes @-reference for project section', () => { - const dir = makeTempProject({ - '.planning/PROJECT.md': '# My Project\n\n## What This Is\n\nA great app.\n', - '.planning/config.json': JSON.stringify({ claude_md_assembly: { mode: 'link' } }), - }); - - cmdGenerateClaudeMd(dir, { output: path.join(dir, 'CLAUDE.md') }, false); - - const content = fs.readFileSync(path.join(dir, 'CLAUDE.md'), 'utf-8'); - assert.ok(content.includes('@.planning/PROJECT.md'), 'should contain @-reference for project'); - assert.ok(!content.includes('A great app.'), 'should not inline project content'); -}); - -test('embed mode (default) inlines content as before', () => { - const dir = makeTempProject({ - '.planning/codebase/ARCHITECTURE.md': '# Architecture\n\n- monolith\n', - }); - - cmdGenerateClaudeMd(dir, { output: path.join(dir, 'CLAUDE.md') }, false); - - const content = fs.readFileSync(path.join(dir, 'CLAUDE.md'), 'utf-8'); - assert.ok(content.includes('- monolith'), 'embed mode should inline content'); - assert.ok(!content.includes('@.planning/codebase/ARCHITECTURE.md'), 'embed mode should not write @-reference'); -}); - -test('per-block override: link only architecture, embed others', () => { - const dir = makeTempProject({ - '.planning/PROJECT.md': '# Proj\n\n## What This Is\n\nApp.\n', - '.planning/codebase/ARCHITECTURE.md': '# Arch\n\n- layers\n', - '.planning/config.json': JSON.stringify({ claude_md_assembly: { mode: 'embed', blocks: { architecture: 'link' } } }), - }); - - cmdGenerateClaudeMd(dir, { output: path.join(dir, 'CLAUDE.md') }, false); - - const content = fs.readFileSync(path.join(dir, 'CLAUDE.md'), 'utf-8'); - assert.ok(content.includes('@.planning/codebase/ARCHITECTURE.md'), 'architecture should use link'); - assert.ok(!content.includes('@.planning/PROJECT.md'), 'project should use embed'); - assert.ok(content.includes('App.'), 'project content should be inlined'); -}); - -test('link mode falls back to embed for workflow section (no linkable source)', () => { - const dir = makeTempProject({ - '.planning/config.json': JSON.stringify({ claude_md_assembly: { mode: 'link' } }), - }); - - cmdGenerateClaudeMd(dir, { output: path.join(dir, 'CLAUDE.md') }, false); - - const content = fs.readFileSync(path.join(dir, 'CLAUDE.md'), 'utf-8'); - // workflow section should still be inlined (it has no linkPath) - assert.ok(!content.includes('@GSD defaults'), 'workflow should not write @GSD defaults'); - assert.ok(content.includes('GSD Workflow Enforcement'), 'workflow content should be embedded inline'); -}); - -test('link mode falls back to embed when source file is missing (hasFallback)', () => { - const dir = makeTempProject({ - '.planning/config.json': JSON.stringify({ claude_md_assembly: { mode: 'link' } }), - }); - // No .planning/codebase/ARCHITECTURE.md — generator will use fallback - - cmdGenerateClaudeMd(dir, { output: path.join(dir, 'CLAUDE.md') }, false); - - const content = fs.readFileSync(path.join(dir, 'CLAUDE.md'), 'utf-8'); - assert.ok(!content.includes('@.planning/codebase/ARCHITECTURE.md'), 'fallback section should not write @-reference'); -}); diff --git a/tests/enh-2446-milestones-drift.test.cjs b/tests/enh-2446-milestones-drift.test.cjs deleted file mode 100644 index 4b3df5c77..000000000 --- a/tests/enh-2446-milestones-drift.test.cjs +++ /dev/null @@ -1,109 +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-health MILESTONES.md drift detection (#2446). - */ - -const { test, after } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const os = require('node:os'); -const helpers = require('./helpers.cjs'); - -const { cmdValidateHealth } = require('../gsd-core/bin/lib/verify.cjs'); - -const _dirsToClean = []; -after(() => { for (const d of _dirsToClean) helpers.cleanup(d); }); - -function makeTempProject(files = {}) { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2446-')); - _dirsToClean.push(dir); - fs.mkdirSync(path.join(dir, '.planning', 'milestones'), { recursive: true }); - for (const [rel, content] of Object.entries(files)) { - const abs = path.join(dir, rel); - fs.mkdirSync(path.dirname(abs), { recursive: true }); - fs.writeFileSync(abs, content, 'utf-8'); - } - return dir; -} - -test('W018: warns when archived snapshot has no MILESTONES.md entry', () => { - const dir = makeTempProject({ - '.planning/PROJECT.md': '# P\n\n## What This Is\n\nX\n\n## Core Value\n\nY\n\n## Requirements\n\nZ\n', - '.planning/ROADMAP.md': '# Roadmap\n', - '.planning/STATE.md': '# State\n', - '.planning/config.json': '{}', - '.planning/milestones/v1.0-ROADMAP.md': '# Milestone v1.0\n', - // No MILESTONES.md entry for v1.0 - }); - - const result = cmdValidateHealth(dir, { repair: false }, false); - - const w018 = result.warnings.find(w => w.code === 'W018'); - assert.ok(w018, 'W018 warning should be emitted'); - assert.ok(w018.message.includes('v1.0'), 'warning should mention v1.0'); - assert.ok(w018.repairable, 'W018 should be marked repairable'); -}); - -test('no W018 when all snapshots have MILESTONES.md entries', () => { - const dir = makeTempProject({ - '.planning/PROJECT.md': '# P\n\n## What This Is\n\nX\n\n## Core Value\n\nY\n\n## Requirements\n\nZ\n', - '.planning/ROADMAP.md': '# Roadmap\n', - '.planning/STATE.md': '# State\n', - '.planning/config.json': '{}', - '.planning/milestones/v1.0-ROADMAP.md': '# Milestone v1.0\n', - '.planning/MILESTONES.md': '# Milestones\n\n## v1.0 My App (Shipped: 2026-01-01)\n\n---\n\n', - }); - - const result = cmdValidateHealth(dir, { repair: false }, false); - - const w018 = result.warnings.find(w => w.code === 'W018'); - assert.strictEqual(w018, undefined, 'no W018 when entries are present'); -}); - -test('no W018 when milestones archive dir is empty', () => { - const dir = makeTempProject({ - '.planning/PROJECT.md': '# P\n\n## What This Is\n\nX\n\n## Core Value\n\nY\n\n## Requirements\n\nZ\n', - '.planning/ROADMAP.md': '# Roadmap\n', - '.planning/STATE.md': '# State\n', - '.planning/config.json': '{}', - // No snapshots in milestones/ - }); - - const result = cmdValidateHealth(dir, { repair: false }, false); - - const w018 = result.warnings.find(w => w.code === 'W018'); - assert.strictEqual(w018, undefined, 'no W018 with empty archive dir'); -}); - -test('--backfill synthesizes missing MILESTONES.md entry from snapshot', () => { - const dir = makeTempProject({ - '.planning/PROJECT.md': '# P\n\n## What This Is\n\nX\n\n## Core Value\n\nY\n\n## Requirements\n\nZ\n', - '.planning/ROADMAP.md': '# Roadmap\n', - '.planning/STATE.md': '# State\n', - '.planning/config.json': '{}', - '.planning/milestones/v1.0-ROADMAP.md': '# Milestone v1.0 First Release\n', - }); - - cmdValidateHealth(dir, { repair: true, backfill: true }, false); - - const milestonesPath = path.join(dir, '.planning', 'MILESTONES.md'); - assert.ok(fs.existsSync(milestonesPath), 'MILESTONES.md should be created'); - const content = fs.readFileSync(milestonesPath, 'utf-8'); - assert.ok(content.includes('## v1.0'), 'backfilled entry should contain v1.0'); - assert.ok(content.includes('Backfilled'), 'should note it was backfilled'); -}); - -test('health.md mentions --backfill flag', () => { - const healthMd = fs.readFileSync( - path.join(__dirname, '../gsd-core/workflows/health.md'), 'utf-8' - ); - assert.ok(healthMd.includes('--backfill'), 'health.md should document --backfill'); - assert.ok(healthMd.includes('W018'), 'health.md should list W018 error code'); - assert.ok(healthMd.includes('backfillMilestones'), 'repair_actions should include backfillMilestones'); -}); diff --git a/tests/enh-2448-artifact-registry.test.cjs b/tests/enh-2448-artifact-registry.test.cjs deleted file mode 100644 index d8e802343..000000000 --- a/tests/enh-2448-artifact-registry.test.cjs +++ /dev/null @@ -1,140 +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 canonical artifact registry and gsd-health W019 lint (#2448). - */ - -const { test, describe, after } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const os = require('node:os'); -const helpers = require('./helpers.cjs'); - -const { isCanonicalPlanningFile, CANONICAL_EXACT } = require('../gsd-core/bin/lib/artifacts.cjs'); -const { cmdValidateHealth } = require('../gsd-core/bin/lib/verify.cjs'); - -const _dirsToClean = []; -after(() => { for (const d of _dirsToClean) helpers.cleanup(d); }); - -function makeTempProject(files = {}) { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2448-')); - _dirsToClean.push(dir); - fs.mkdirSync(path.join(dir, '.planning', 'phases'), { recursive: true }); - for (const [rel, content] of Object.entries(files)) { - const abs = path.join(dir, rel); - fs.mkdirSync(path.dirname(abs), { recursive: true }); - fs.writeFileSync(abs, content, 'utf-8'); - } - return dir; -} - -const BASE_FILES = { - '.planning/PROJECT.md': '# P\n\n## What This Is\n\nX\n\n## Core Value\n\nY\n\n## Requirements\n\nZ\n', - '.planning/ROADMAP.md': '# Roadmap\n', - '.planning/STATE.md': '# State\n', - '.planning/config.json': '{}', -}; - -describe('artifacts.cjs — isCanonicalPlanningFile', () => { - test('returns true for all exact canonical names', () => { - for (const name of CANONICAL_EXACT) { - assert.ok(isCanonicalPlanningFile(name), `Expected ${name} to be canonical`); - } - }); - - test('returns true for version-stamped milestone audit file', () => { - assert.ok(isCanonicalPlanningFile('v1.0-MILESTONE-AUDIT.md')); - assert.ok(isCanonicalPlanningFile('v2.3.1-MILESTONE-AUDIT.md')); - }); - - test('returns true for RETROSPECTIVE.md (produced by /gsd-complete-milestone)', () => { - assert.strictEqual(isCanonicalPlanningFile('RETROSPECTIVE.md'), true); - }); - - test('returns false for clearly non-canonical names', () => { - assert.strictEqual(isCanonicalPlanningFile('MY-NOTES.md'), false); - assert.strictEqual(isCanonicalPlanningFile('scratch.md'), false); - assert.strictEqual(isCanonicalPlanningFile('random-output.md'), false); - }); - - test('returns false for phase-level artifacts at the root (they belong in phases/)', () => { - assert.strictEqual(isCanonicalPlanningFile('01-CONTEXT.md'), false); - assert.strictEqual(isCanonicalPlanningFile('01-01-PLAN.md'), false); - }); -}); - -describe('gsd-health W019 — unrecognized .planning/ root files', () => { - test('W019 fires for a non-canonical .md file at .planning/ root', () => { - const dir = makeTempProject({ - ...BASE_FILES, - '.planning/MY-NOTES.md': '# notes\n', - }); - - const result = cmdValidateHealth(dir, { repair: false }, false); - - const w019 = result.warnings.find(w => w.code === 'W019'); - assert.ok(w019, 'W019 should be emitted for unrecognized file'); - assert.ok(w019.message.includes('MY-NOTES.md'), 'warning should name the file'); - assert.strictEqual(w019.repairable, false, 'W019 is not auto-repairable'); - }); - - test('no W019 for canonical files', () => { - const dir = makeTempProject({ ...BASE_FILES }); - - const result = cmdValidateHealth(dir, { repair: false }, false); - - const w019 = result.warnings.find(w => w.code === 'W019'); - assert.strictEqual(w019, undefined, 'no W019 for canonical files'); - }); - - test('no W019 for phase subdirectory files (only root is checked)', () => { - const dir = makeTempProject({ - ...BASE_FILES, - '.planning/phases/01-foundation/01-01-PLAN.md': '---\nphase: "1"\n---\n', - }); - - const result = cmdValidateHealth(dir, { repair: false }, false); - - const w019 = result.warnings.find(w => w.code === 'W019'); - assert.strictEqual(w019, undefined, 'phase subdir files not flagged by W019'); - }); - - test('no W019 for version-stamped files like vX.Y-MILESTONE-AUDIT.md', () => { - const dir = makeTempProject({ - ...BASE_FILES, - '.planning/v1.0-MILESTONE-AUDIT.md': '# Audit\n', - }); - - const result = cmdValidateHealth(dir, { repair: false }, false); - - const w019 = result.warnings.find(w => w.code === 'W019'); - assert.strictEqual(w019, undefined, 'version-stamped audit file is canonical'); - }); - - test('multiple unrecognized files produce multiple W019 warnings', () => { - const dir = makeTempProject({ - ...BASE_FILES, - '.planning/scratch.md': '# scratch\n', - '.planning/temp-notes.md': '# temp\n', - }); - - const result = cmdValidateHealth(dir, { repair: false }, false); - - const w019s = result.warnings.filter(w => w.code === 'W019'); - assert.strictEqual(w019s.length, 2, 'one W019 per unrecognized file'); - }); - - test('templates/README.md exists and documents W019', () => { - const readme = fs.readFileSync( - path.join(__dirname, '../gsd-core/templates/README.md'), 'utf-8' - ); - assert.ok(readme.includes('W019'), 'README.md documents W019'); - assert.ok(readme.includes('artifacts.cjs'), 'README.md references artifacts.cjs for adding new artifacts'); - assert.ok(readme.includes('PROJECT.md'), 'README.md lists PROJECT.md as canonical'); - }); -}); diff --git a/tests/enh-2792-namespace-skills.test.cjs b/tests/enh-2792-namespace-skills.test.cjs deleted file mode 100644 index 464fde31c..000000000 --- a/tests/enh-2792-namespace-skills.test.cjs +++ /dev/null @@ -1,337 +0,0 @@ -'use strict'; - -// allow-test-rule: source-text-is-the-product -// commands/gsd/*.md files ARE what the runtime loads — testing their -// frontmatter content tests the deployed system-prompt contract. - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const { parseRequires } = require('./helpers/nested-layout.cjs'); - -const COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); - -const NAMESPACE_SKILLS = [ - { file: 'ns-workflow.md', name: 'gsd-workflow' }, - { file: 'ns-project.md', name: 'gsd-project' }, - { file: 'ns-review.md', name: 'gsd-quality' }, - { file: 'ns-context.md', name: 'gsd-context' }, - { file: 'ns-manage.md', name: 'gsd-manage' }, - { file: 'ns-ideate.md', name: 'gsd-ideate' }, -]; - -// Route targets named in any namespace body. The cross-reference test below -// asserts that every one of these resolves to a surviving command file or to -// a known consolidated parent (which absorbs flag-form invocations of folded -// skills, e.g. `gsd-map-codebase --fast` for the former `gsd-scan`). -const FLAG_FORM_PARENTS = new Set([ - 'gsd-code-review', // --fix absorbs former gsd-code-review-fix - 'gsd-map-codebase', // --fast absorbs scan, --query absorbs intel -]); - -/** - * Parse the leading YAML frontmatter block of a markdown file into a - * shallow `{ key: value }` map plus the trailing body. Splits on `\r?\n` - * for CRLF tolerance and uses trimmed-line equality for the `---` - * delimiters so whitespace-padded delimiter lines are accepted. - */ -function parseFrontmatter(content) { - const lines = content.split(/\r?\n/); - let openIdx = -1; - let closeIdx = -1; - for (let i = 0; i < lines.length; i += 1) { - if (lines[i].trim() === '---') { - if (openIdx === -1) openIdx = i; - else { closeIdx = i; break; } - } - } - assert.ok(openIdx !== -1 && closeIdx !== -1, 'frontmatter block must be delimited by --- on its own lines'); - const fm = {}; - for (const line of lines.slice(openIdx + 1, closeIdx)) { - const m = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/); - if (!m) continue; - const [, key, raw] = m; - const value = raw.trim().replace(/^["']|["']$/g, ''); - fm[key] = value; - } - fm._body = lines.slice(closeIdx + 1).join('\n'); - return fm; -} - -function readNamespaceFile(file) { - const filePath = path.join(COMMANDS_DIR, file); - assert.ok(fs.existsSync(filePath), `${file} must exist at ${filePath}`); - return { filePath, ...parseFrontmatter(fs.readFileSync(filePath, 'utf-8')) }; -} - -// ── Frontmatter contract ─────────────────────────────────────────────── - -describe('Namespace skill files exist with correct name', () => { - for (const { file, name } of NAMESPACE_SKILLS) { - test(`${file} — name field is hyphen-form ${name}`, () => { - const fm = readNamespaceFile(file); - assert.strictEqual( - fm.name, - name, - `name: in ${file} must be ${name} (hyphen form per #2858), got: ${fm.name}`, - ); - }); - } -}); - -describe('Namespace skill descriptions are keyword-tag format', () => { - for (const { file } of NAMESPACE_SKILLS) { - test(`${file} — description ≤ 60 chars`, () => { - const fm = readNamespaceFile(file); - assert.ok( - fm.description.length <= 60, - `${file} description must be ≤ 60 chars, got ${fm.description.length}: ${fm.description}`, - ); - }); - - test(`${file} — description contains a pipe separator`, () => { - const fm = readNamespaceFile(file); - assert.ok( - fm.description.includes('|'), - `${file} description must contain | pipe separator, got: ${fm.description}`, - ); - }); - - test(`${file} — description does not start with prose ("Use " / "This skill")`, () => { - const { description } = readNamespaceFile(file); - assert.ok( - !description.startsWith('Use ') && !description.startsWith('This skill'), - `${file} description must not start with "Use " or "This skill", got: ${description}`, - ); - }); - } -}); - -// ── allowed-tools must include Skill ────────────────────────────────── - -describe('Namespace skills permit Skill execution', () => { - for (const { file } of NAMESPACE_SKILLS) { - test(`${file} — allowed-tools includes Skill`, () => { - const filePath = path.join(COMMANDS_DIR, file); - const raw = fs.readFileSync(filePath, 'utf-8'); - const lines = raw.split(/\r?\n/); - const startIdx = lines.findIndex((l) => l.trim() === 'allowed-tools:'); - assert.ok(startIdx !== -1, `${file} must declare an allowed-tools block`); - const tools = []; - for (let i = startIdx + 1; i < lines.length; i += 1) { - const m = lines[i].match(/^\s+-\s+(\S+)/); - if (!m) break; - tools.push(m[1]); - } - assert.ok( - tools.includes('Skill'), - `${file} body invokes the Skill tool but allowed-tools does not include Skill (got: ${tools.join(', ')})`, - ); - }); - } -}); - -// ── Body contains routing table ─────────────────────────────────────── - -describe('Namespace skill bodies carry a routing table', () => { - for (const { file } of NAMESPACE_SKILLS) { - test(`${file} — body contains "| User wants" table header`, () => { - const fm = readNamespaceFile(file); - const lines = fm._body.split('\n'); - const hasHeader = lines.some((l) => l.includes('| User wants')); - assert.ok(hasHeader, `${file} body must contain a routing table starting with "| User wants"`); - }); - - test(`${file} — body has at least one Invoke target`, () => { - const fm = readNamespaceFile(file); - const hasInvoke = /\bgsd-[a-z-]+/i.test(fm._body); - assert.ok(hasInvoke, `${file} body must reference at least one gsd-* sub-skill`); - }); - } -}); - -// ── Context guard contract on gsd-health ────────────────────────────── -// Asserts the `--context` surface promised by #2792 is wired through to -// both the command frontmatter and the workflow body. The classifier -// itself is covered by tests/context-utilization.test.cjs and the SDK -// CLI by tests/validate-context.test.cjs. - -describe('gsd-health --context flag is wired into command + workflow', () => { - const HEALTH_CMD = path.join(COMMANDS_DIR, 'health.md'); - const HEALTH_WORKFLOW = path.join(__dirname, '..', 'gsd-core', 'workflows', 'health.md'); - - test('commands/gsd/health.md argument-hint advertises --context', () => { - const raw = fs.readFileSync(HEALTH_CMD, 'utf-8'); - const fm = parseFrontmatter(raw); - assert.ok( - fm['argument-hint'] && fm['argument-hint'].includes('--context'), - `health.md argument-hint must include --context, got: ${fm['argument-hint']}`, - ); - }); - - test('commands/gsd/health.md body documents the three-state utilization table', () => { - const raw = fs.readFileSync(HEALTH_CMD, 'utf-8'); - const body = parseFrontmatter(raw)._body.toLowerCase(); - assert.ok(body.includes('healthy'), 'body must name the healthy state'); - assert.ok(body.includes('warning'), 'body must name the warning state'); - assert.ok(body.includes('critical'), 'body must name the critical state'); - assert.ok( - body.includes('60%') && body.includes('70%'), - 'body must reference the 60% and 70% threshold boundaries', - ); - }); - - test('gsd-core/workflows/health.md has a context_check step', () => { - const raw = fs.readFileSync(HEALTH_WORKFLOW, 'utf-8'); - assert.match( - raw, - //, - 'workflow must define a branch', - ); - }); - - test('workflow context_check invokes gsd-sdk query validate.context', () => { - const raw = fs.readFileSync(HEALTH_WORKFLOW, 'utf-8'); - // Extract just the context_check step's body so a stray reference - // elsewhere in the file can't satisfy this assertion. - const stepMatch = raw.match(/([\s\S]*?)<\/step>/); - assert.ok(stepMatch, 'context_check step must be a closed ... block'); - const stepBody = stepMatch[1]; - // After #3797 architectural fix, callsites use gsd_run - assert.match( - stepBody, - /gsd_run\s+query\s+validate\.context/, - 'context_check must call `gsd_run query validate.context`', - ); - assert.match(stepBody, /--tokens-used/, 'context_check must pass --tokens-used'); - assert.match(stepBody, /--context-window/, 'context_check must pass --context-window'); - }); -}); - -// ── Namespace nesting completeness (#69) ────────────────────────────── -// Guards that the install-layout nesting invariant (<=6 top-level entries) -// is always satisfiable: every router's requires list points at real files, -// every concrete skill is covered by at least one router, and each router's -// body table stays in sync with its requires list. - -const NS_FILES = NAMESPACE_SKILLS.map((ns) => ns.file); - -describe('namespace nesting completeness (#69)', () => { - // Build the concrete-skill set once (all *.md minus ns-*.md) - const allFiles = fs.readdirSync(COMMANDS_DIR).filter((f) => f.endsWith('.md')); - const concreteStemSet = new Set( - allFiles - .filter((f) => !f.startsWith('ns-')) - .map((f) => f.replace(/\.md$/, '')), - ); - - // Build per-router requires and the union over all routers - const routerRequires = new Map(); // stem -> string[] - for (const f of NS_FILES) { - const stem = f.replace(/\.md$/, ''); - const content = fs.readFileSync(path.join(COMMANDS_DIR, f), 'utf-8'); - routerRequires.set(stem, parseRequires(content)); - } - const allRoutedStems = new Set([...routerRequires.values()].flat()); - - test('every router requires entry resolves to a real concrete skill file', () => { - const bad = []; - for (const [routerStem, children] of routerRequires) { - for (const child of children) { - if (!fs.existsSync(path.join(COMMANDS_DIR, `${child}.md`))) { - bad.push(`${routerStem} → ${child}`); - } - } - } - assert.deepStrictEqual( - bad, - [], - `Router requires entries with no matching commands/gsd/.md: ${bad.join(', ')}`, - ); - }); - - test('every concrete skill is routed by at least one namespace router', () => { - const unrouted = [...concreteStemSet].filter((stem) => !allRoutedStems.has(stem)); - assert.deepStrictEqual( - unrouted, - [], - `Concrete skills not routed by any ns-*.md (add to a router's requires:): ${unrouted.join(', ')}`, - ); - }); - - test("each router's routing-table rows reference only its own required sub-skills (plus flag variants)", () => { - const bad = []; - for (const [routerStem, children] of routerRequires) { - const childSet = new Set(children); - const content = fs.readFileSync(path.join(COMMANDS_DIR, `${routerStem}.md`), 'utf-8'); - const fm = parseFrontmatter(content); - // Extract gsd- tokens from table data rows (last cell), strip flags - for (const line of fm._body.split('\n')) { - if (!line.startsWith('|') || /^\|[\s\-:|]+\|?\s*$/.test(line)) continue; - const cells = line.split('|').map((c) => c.trim()).filter(Boolean); - if (cells.length < 2) continue; - const lastCell = cells[cells.length - 1]; - for (const match of lastCell.matchAll(/\bgsd-([a-z][a-z0-9-]*)/g)) { - const stem = match[1]; - if (!childSet.has(stem)) { - bad.push(`${routerStem}: body table references gsd-${stem} but it's not in requires`); - } - } - } - } - assert.deepStrictEqual( - bad, - [], - `Routing table / requires mismatch:\n${bad.join('\n')}`, - ); - }); -}); - -// ── Cross-reference: every routed sub-skill must exist ───────────────── -// This is the regression guard the original PR lacked. Without it, -// post-#2790 consolidations can quietly invalidate router targets again. - -describe('Namespace router targets resolve to surviving skills', () => { - // Build the post-consolidation surviving set once. - const surviving = new Set(); - for (const f of fs.readdirSync(COMMANDS_DIR)) { - if (!f.endsWith('.md')) continue; - const base = f.replace(/\.md$/, ''); - if (base.startsWith('ns-')) continue; // namespace routers themselves - surviving.add(`gsd-${base}`); - // The PR #2858 rename canonicalized extract_learnings → extract-learnings. - // Until #2790 rebases onto current main, accept either source filename - // as resolving to the canonical hyphenated identifier. - if (base === 'extract_learnings') surviving.add('gsd-extract-learnings'); - } - - for (const { file } of NAMESPACE_SKILLS) { - test(`${file} — every routing target resolves`, () => { - const fm = readNamespaceFile(file); - // Extract every gsd- token that appears in a table-row right column. - // Strip flag suffixes (`gsd-foo --bar` → `gsd-foo`) before resolving. - const targets = new Set(); - for (const line of fm._body.split('\n')) { - // Only consider markdown table data rows: lines that start with `|` - // and have content between pipes. Skip header / separator rows. - if (!line.startsWith('|') || /^\|[\s\-:|]+\|?\s*$/.test(line)) continue; - const cells = line.split('|').map((c) => c.trim()).filter(Boolean); - if (cells.length < 2) continue; - for (const m of cells[cells.length - 1].matchAll(/\bgsd-[a-z][a-z0-9-]*/g)) { - targets.add(m[0]); - } - } - assert.ok(targets.size > 0, `${file} routing table must reference at least one gsd-* target`); - const unresolved = [...targets].filter( - (t) => !surviving.has(t) && !FLAG_FORM_PARENTS.has(t), - ); - assert.deepStrictEqual( - unresolved, - [], - `${file} routes to skills that don't exist in commands/gsd/: ${unresolved.join(', ')}`, - ); - }); - } -}); diff --git a/tests/enh-3209-plan-phase-ingest-adr.test.cjs b/tests/enh-3209-plan-phase-ingest-adr.test.cjs deleted file mode 100644 index 8dfac33c0..000000000 --- a/tests/enh-3209-plan-phase-ingest-adr.test.cjs +++ /dev/null @@ -1,80 +0,0 @@ -// allow-test-rule: source-text-is-the-product -// These assertions validate shipped workflow/command markdown contracts. - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const ROOT = path.join(__dirname, '..'); -const COMMAND_PATH = path.join(ROOT, 'commands', 'gsd', 'plan-phase.md'); -const WORKFLOW_PATH = path.join(ROOT, 'gsd-core', 'workflows', 'plan-phase.md'); -const DOCS_COMMANDS_PATH = path.join(ROOT, 'docs', 'COMMANDS.md'); - -function read(filePath) { - return fs.readFileSync(filePath, 'utf8'); -} - -describe('enh #3209: plan-phase ADR ingest express path', () => { - test('command argument-hint advertises --ingest and --ingest-format', () => { - const command = read(COMMAND_PATH); - assert.ok(command.includes('--ingest '), - 'plan-phase command argument-hint must include --ingest '); - assert.ok(command.includes('--ingest-format '), - 'plan-phase command argument-hint must include --ingest-format selector'); - }); - - test('workflow parses --ingest and --ingest-format flags', () => { - const workflow = read(WORKFLOW_PATH); - assert.ok(workflow.includes('--ingest '), - 'plan-phase workflow argument parsing must mention --ingest'); - assert.ok(workflow.includes('--ingest-format'), - 'plan-phase workflow argument parsing must mention --ingest-format'); - }); - - test('workflow has explicit mutual exclusion guard for --prd and --ingest', () => { - const workflow = read(WORKFLOW_PATH); - assert.ok( - workflow.includes('cannot combine `--prd` with `--ingest`') || - workflow.includes('mutually exclusive'), - 'plan-phase workflow must fail fast when --prd and --ingest are both provided' - ); - }); - - test('workflow defines an ADR ingest express-path step', () => { - const workflow = read(WORKFLOW_PATH); - assert.ok(/##\s*(?:\d+(?:\.\d+)*)?\.?\s*Handle ADR Ingest Express Path/i.test(workflow), - 'plan-phase workflow must include a dedicated ADR ingest express-path step'); - assert.ok(workflow.includes('ADR Ingest Express Path'), - 'workflow must display ADR ingest express-path banner text'); - }); - - test('ADR ingest context template includes scope fence and ADR source attribution', () => { - const workflow = read(WORKFLOW_PATH); - assert.ok(workflow.includes(''), - 'ADR ingest context template must include for hard out-of-scope exclusions'); - assert.ok(workflow.includes('Source:** ADR Ingest Express Path'), - 'ADR ingest context template must tag source as ADR Ingest Express Path'); - }); - - test('workflow documents status gate and no-decisions fallback', () => { - const workflow = read(WORKFLOW_PATH); - assert.ok( - workflow.includes('Reject `superseded`/`rejected`/`deprecated`') || - workflow.includes('reject `superseded`/`rejected`/`deprecated`') || - /superseded.*rejected.*deprecated/i.test(workflow), - 'ADR ingest workflow must include status gate for non-active ADRs' - ); - assert.ok( - workflow.includes('empty-decisions fallback') || - workflow.includes('fall back to discuss-phase'), - 'ADR ingest workflow must document fallback when no locked decisions are present' - ); - }); - - test('docs COMMANDS advertises --ingest flag for /gsd-plan-phase', () => { - const commands = read(DOCS_COMMANDS_PATH); - assert.ok(commands.includes('--ingest '), - 'docs/COMMANDS.md must document --ingest for /gsd-plan-phase'); - }); -}); diff --git a/tests/enh-968-region-scoped-negative-grep.test.cjs b/tests/enh-968-region-scoped-negative-grep.test.cjs deleted file mode 100644 index 5e76a4650..000000000 --- a/tests/enh-968-region-scoped-negative-grep.test.cjs +++ /dev/null @@ -1,823 +0,0 @@ -// allow-test-rule: source-text-is-the-product #968 -// Enhancement #968: region-scoped negative gate detector + guidance docs. -// Tests the pure function scanFileWideNegativeGateConflict exported from -// verify.cjs, plus CLI integration and doc-contract assertions. - -'use strict'; - -const { test, describe, before, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const { spawnSync } = require('node:child_process'); -const os = require('node:os'); -const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs'); - -// Build path to built verify.cjs -const VERIFY_CJS = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'verify.cjs'); - -// Build paths to doc files -const PLANNER_MD = path.join(__dirname, '..', 'agents', 'gsd-planner.md'); -const ANTIPATTERNS_MD = path.join(__dirname, '..', 'gsd-core', 'references', 'planner-antipatterns.md'); -// PLAN_MD_REF removed — was unused (doc-contract cases test PLANNER_MD and ANTIPATTERNS_MD only) - -// ─── Fixture helpers ─────────────────────────────────────────────────────────── - -/** - * Build a minimal two-task plan fixture. - * taskA: file=app/page.py, gateText=the verify/acceptance_criteria block, actionText=action block - * taskB: file=app/page.py (default) or otherFile, action text - * allowlistMarker: optional HTML comment to insert at the top - */ -function makeTwoTaskPlan({ - taskAFile = 'app/page.py', - taskAGate = '! grep -Eq \'await .*refresh\' app/page.py', - taskAAction = 'Refactor the factory to be synchronous.', - taskBFile = 'app/page.py', - taskBAction = 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', - allowlistMarker = '', -} = {}) { - const lines = [ - '---', - 'phase: 01-test', - 'plan: 01', - 'type: execute', - 'wave: 1', - 'depends_on: []', - `files_modified: [${taskAFile}, ${taskBFile}]`, - 'autonomous: true', - 'must_haves:', - ' - AC1', - '---', - '', - '# Test Plan', - '', - ]; - - if (allowlistMarker) { - lines.push(allowlistMarker, ''); - } - - // Task A: the one with the negative gate - lines.push(''); - lines.push('Task A: factory refactor'); - lines.push(`${taskAFile}`); - lines.push(`${taskAAction}`); - lines.push(`${taskAGate}`); - lines.push('Factory is synchronous.'); - lines.push(''); - lines.push(''); - - // Task B: the sibling that requires the construct - lines.push(''); - lines.push('Task B: reindex handler'); - lines.push(`${taskBFile}`); - lines.push(`${taskBAction}`); - lines.push('npm test'); - lines.push('Handler is in place.'); - lines.push(''); - - return lines.join('\n'); -} - -/** - * Build a single-task plan (no sibling). - */ -function makeSingleTaskPlan({ - taskFile = 'app/page.py', - taskGate = '! grep -Eq \'await .*refresh\' app/page.py', - taskAction = 'Refactor the factory to be synchronous.', -} = {}) { - return [ - '---', - 'phase: 01-test', - 'plan: 01', - 'type: execute', - 'wave: 1', - 'depends_on: []', - `files_modified: [${taskFile}]`, - 'autonomous: true', - 'must_haves:', - ' - AC1', - '---', - '', - '# Test Plan', - '', - '', - 'Task A: factory refactor', - `${taskFile}`, - `${taskAction}`, - `${taskGate}`, - 'Factory is synchronous.', - '', - ].join('\n'); -} - -// ─── Group 1: pure-function unit tests ──────────────────────────────────────── - -describe('scanFileWideNegativeGateConflict — pure unit tests', () => { - let scan; - - before(() => { - const verify = require(VERIFY_CJS); - scan = verify.scanFileWideNegativeGateConflict; - assert.ok(typeof scan === 'function', 'scanFileWideNegativeGateConflict must be exported'); - }); - - // Case 1: basic WARN path — Task A bans PAT file-wide, Task B requires it in same file - test('case 1 — file-wide ban + sibling requires → WARN', () => { - const content = makeTwoTaskPlan({ - taskAGate: "! grep -Eq 'await .*refresh' app/page.py", - taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', - }); - const result = scan(content); - assert.ok(Array.isArray(result.warnings), 'must return { warnings: [] }'); - assert.ok( - result.warnings.length >= 1, - `expected at least 1 warning, got: ${JSON.stringify(result.warnings)}`, - ); - assert.ok( - result.warnings[0].includes('Region-scope conflict (#968)'), - `warning must mention Region-scope conflict (#968), got: ${result.warnings[0]}`, - ); - assert.ok( - result.warnings[0].includes('await .*refresh'), - `warning must mention the PAT, got: ${result.warnings[0]}`, - ); - assert.ok( - result.warnings[0].includes('app/page.py'), - `warning must mention the file, got: ${result.warnings[0]}`, - ); - assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true'); - }); - - // Case 2: region-scoped via sed → NO warn - test('case 2 — region-scoped via sed pipe → NO warn', () => { - const content = makeTwoTaskPlan({ - taskAGate: "! sed -n '12,40p' app/page.py | grep -Eq 'await .*refresh'", - taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', - }); - const result = scan(content); - assert.strictEqual( - result.warnings.filter(w => w.includes('#968')).length, - 0, - `sed-piped grep is region-scoped — must not warn, got: ${JSON.stringify(result.warnings)}`, - ); - }); - - // Case 2b: region-scoped via awk → NO warn - test('case 2b — region-scoped via awk pipe → NO warn', () => { - const content = makeTwoTaskPlan({ - taskAGate: "! awk '/^def make_page/,/^def /' app/page.py | grep -Eq 'await .*refresh'", - taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', - }); - const result = scan(content); - assert.strictEqual( - result.warnings.filter(w => w.includes('#968')).length, - 0, - `awk-piped grep is region-scoped — must not warn, got: ${JSON.stringify(result.warnings)}`, - ); - }); - - // Case 3: single task file-wide ban, no sibling → NO warn - test('case 3 — single task, no sibling → NO warn', () => { - const content = makeSingleTaskPlan({ - taskGate: "! grep -Eq 'await .*refresh' app/page.py", - }); - const result = scan(content); - assert.strictEqual( - result.warnings.filter(w => w.includes('#968')).length, - 0, - `single task (no sibling) must not warn, got: ${JSON.stringify(result.warnings)}`, - ); - }); - - // Case 4: sibling requires PAT but lists a different file → NO warn - test('case 4 — sibling lists different file → NO warn', () => { - const content = makeTwoTaskPlan({ - taskAFile: 'app/page.py', - taskAGate: "! grep -Eq 'await .*refresh' app/page.py", - taskBFile: 'app/other.py', - taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', - }); - const result = scan(content); - assert.strictEqual( - result.warnings.filter(w => w.includes('#968')).length, - 0, - `sibling with different file must not warn, got: ${JSON.stringify(result.warnings)}`, - ); - }); - - // Case 5: sibling lists same file but action lacks PAT → NO warn - test('case 5 — sibling lists same file but action lacks PAT → NO warn', () => { - const content = makeTwoTaskPlan({ - taskAGate: "! grep -Eq 'await .*refresh' app/page.py", - taskBAction: 'Add a post-reindex handler that calls bridge.sync() to repopulate state.', - }); - const result = scan(content); - assert.strictEqual( - result.warnings.filter(w => w.includes('#968')).length, - 0, - `sibling with no PAT in action must not warn, got: ${JSON.stringify(result.warnings)}`, - ); - }); - - // Case 6: positive grep (no !) + sibling → NO warn (positive requirement, not a ban) - test('case 6 — positive grep (no !) + sibling → NO warn', () => { - const content = makeTwoTaskPlan({ - taskAGate: "grep -q 'await .*refresh' app/page.py", - taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', - }); - const result = scan(content); - assert.strictEqual( - result.warnings.filter(w => w.includes('#968')).length, - 0, - `positive grep must not warn, got: ${JSON.stringify(result.warnings)}`, - ); - }); - - // Case 7: inverted grep -v with ! + sibling → NO warn - test('case 7 — inverted grep -vq with ! + sibling → NO warn', () => { - const content = makeTwoTaskPlan({ - taskAGate: "! grep -vq 'await .*refresh' app/page.py", - taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', - }); - const result = scan(content); - assert.strictEqual( - result.warnings.filter(w => w.includes('#968')).length, - 0, - `inverted grep (-v) must not warn, got: ${JSON.stringify(result.warnings)}`, - ); - }); - - // Case 8: allowlist marker present → NO warn - test('case 8 — allowlist marker suppresses warn', () => { - const content = makeTwoTaskPlan({ - taskAGate: "! grep -Eq 'await .*refresh' app/page.py", - taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', - allowlistMarker: '', - }); - const result = scan(content); - assert.strictEqual( - result.warnings.filter(w => w.includes('#968')).length, - 0, - `allowlist marker must suppress warn, got: ${JSON.stringify(result.warnings)}`, - ); - }); - - // Case 9: one task both bans and requires same PAT in same file (no second task) → NO warn - test('case 9 — one task bans and requires PAT (no sibling) → NO warn', () => { - const content = makeSingleTaskPlan({ - taskGate: "! grep -Eq 'await .*refresh' app/page.py", - taskAction: 'Refactor to avoid await refresh, but note that bridge.refresh() is used later.', - }); - const result = scan(content); - assert.strictEqual( - result.warnings.filter(w => w.includes('#968')).length, - 0, - `single task (no sibling B) must not warn, got: ${JSON.stringify(result.warnings)}`, - ); - }); - - // Case 10: count form `grep -c 'PAT' FILE == 0` + sibling → WARN - test('case 10 — count form (grep -c PAT FILE == 0) + sibling → WARN', () => { - const content = makeTwoTaskPlan({ - taskAGate: "grep -c 'await .*refresh' app/page.py == 0", - taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', - }); - const result = scan(content); - assert.ok( - result.warnings.filter(w => w.includes('#968')).length >= 1, - `count form (grep -c ... == 0) must warn, got: ${JSON.stringify(result.warnings)}`, - ); - assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true'); - }); - - // Case 11: bracket form `[ $(grep -c PAT FILE) -eq 0 ]` + sibling → WARN - test('case 11 — bracket form ([ $(grep -c PAT FILE) -eq 0 ]) + sibling → WARN', () => { - const content = makeTwoTaskPlan({ - taskAGate: "[ $(grep -c 'await .*refresh' app/page.py) -eq 0 ]", - taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', - }); - const result = scan(content); - assert.ok( - result.warnings.filter(w => w.includes('#968')).length >= 1, - `bracket form must warn, got: ${JSON.stringify(result.warnings)}`, - ); - assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true'); - }); - - // Case 12: CRLF variant of case 1 → WARN - test('case 12 — CRLF line endings → WARN', () => { - const content = makeTwoTaskPlan({ - taskAGate: "! grep -Eq 'await .*refresh' app/page.py", - taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', - }); - const crlfContent = content.split('\n').join('\r\n'); - const result = scan(crlfContent); - assert.ok( - result.warnings.filter(w => w.includes('#968')).length >= 1, - `CRLF content must still warn, got: ${JSON.stringify(result.warnings)}`, - ); - assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true'); - }); - - // Case 13: backslash line-continuation variant → WARN - test('case 13 — backslash line continuation → WARN', () => { - // Build manually to control exact line continuation - const lines = [ - '---', - 'phase: 01-test', - 'plan: 01', - 'type: execute', - 'wave: 1', - 'depends_on: []', - 'files_modified: [app/page.py]', - 'autonomous: true', - 'must_haves:', - ' - AC1', - '---', - '', - '', - 'Task A: factory refactor', - 'app/page.py', - 'Refactor the factory to be synchronous.', - // Gate split across lines with backslash continuation - "! grep -Eq 'await .*refresh' \\\napp/page.py", - 'Factory is synchronous.', - '', - '', - '', - 'Task B: reindex handler', - 'app/page.py', - 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', - 'npm test', - 'Handler is in place.', - '', - ].join('\n'); - const result = scan(lines); - assert.ok( - result.warnings.filter(w => w.includes('#968')).length >= 1, - `backslash continuation must still warn, got: ${JSON.stringify(result.warnings)}`, - ); - assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true'); - }); - - // Case 14: mixed line with positive gate AND a negative gate, sibling → WARN (on the negative only) - test('case 14 — mixed positive+negative on one segment + sibling → WARN for negative', () => { - const content = makeTwoTaskPlan({ - taskAGate: "grep -c 'X' app/page.py == 1 && ! grep -Eq 'await .*refresh' app/page.py", - taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', - }); - const result = scan(content); - assert.ok( - result.warnings.filter(w => w.includes('#968')).length >= 1, - `mixed line with negative gate + sibling must warn, got: ${JSON.stringify(result.warnings)}`, - ); - assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true'); - }); - - // Case 15: glob file arg `app/*.py` → NO warn (unresolvable path) - test('case 15 — glob file arg → NO warn (unresolvable)', () => { - const content = makeTwoTaskPlan({ - taskAGate: "! grep -Eq 'await .*refresh' app/*.py", - taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', - }); - const result = scan(content); - assert.strictEqual( - result.warnings.filter(w => w.includes('#968')).length, - 0, - `glob file arg must not warn (unresolvable), got: ${JSON.stringify(result.warnings)}`, - ); - }); - - // Case 16: invalid-regex PAT literal fallback → WARN, no exception - test('case 16 — invalid-regex PAT → literal fallback, WARN, no exception', () => { - // "await (refresh" — unbalanced paren, invalid regex - const content = makeTwoTaskPlan({ - taskAGate: "! grep -Eq 'await (refresh' app/page.py", - taskBAction: 'The handler calls await (refresh on bridge to repopulate state.', - }); - let result; - assert.doesNotThrow(() => { - result = scan(content); - }, 'scan must not throw on invalid regex PAT'); - assert.ok( - result.warnings.filter(w => w.includes('#968')).length >= 1, - `invalid-regex PAT with literal match must warn, got: ${JSON.stringify(result.warnings)}`, - ); - }); - - // Case 17: ReDoS-ish PAT (catastrophic backtracking) → no hang, no false warn. - // The sibling action is 5000 'a's — classic ReDoS trigger if we call new RegExp('(a+)+$'). - // Proof-of-no-hang: the test runner's own timeout catches it; a hanging test fails here. - // No timing assertion (flaky) — the linear patternRequiredIn implementation is microsecond-fast. - test('case 17 — catastrophic ReDoS pattern is instant, no hang, no false warn', () => { - const longAs = 'a'.repeat(5000); - const content = makeTwoTaskPlan({ - taskAGate: "! grep -Eq '(a+)+$' app/page.py", - taskBAction: `Reindex handler that processes ${longAs} records and calls bridge.refresh().`, - }); - let result; - assert.doesNotThrow(() => { - result = scan(content); - }, 'scan must not throw on ReDoS-ish PAT'); - // The literal '(a+)+$' is not present in the action text as a substring → no warn. - // (If new RegExp were used, this test would hang before reaching this assertion.) - assert.strictEqual( - result.warnings.filter(w => w.includes('#968')).length, - 0, - `catastrophic PAT '(a+)+$' not literally in action — must not warn, got: ${JSON.stringify(result.warnings)}`, - ); - assert.ok(Array.isArray(result.warnings), 'valid result shape'); - }); - - // Case 23 (mutation-catching): cat producer = file-wide → WARN; sed producer = region-scoped → NO warn - test('case 23a — cat pipe: ! cat app/page.py | grep -Eq PAT + sibling → WARN (file-wide via cat)', () => { - const content = makeTwoTaskPlan({ - taskAGate: "! cat app/page.py | grep -Eq 'await .*refresh'", - taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', - }); - const result = scan(content); - assert.ok( - result.warnings.filter(w => w.includes('#968')).length >= 1, - `cat-piped grep is file-wide — must warn, got: ${JSON.stringify(result.warnings)}`, - ); - assert.ok(result.valid !== false, 'valid must remain true even when #968 warns'); - }); - - test('case 23b — sed pipe: ! sed -n "12,40p" app/page.py | grep -Eq PAT + sibling → NO warn (region-scoped)', () => { - const content = makeTwoTaskPlan({ - taskAGate: "! sed -n '12,40p' app/page.py | grep -Eq 'await .*refresh'", - taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', - }); - const result = scan(content); - assert.strictEqual( - result.warnings.filter(w => w.includes('#968')).length, - 0, - `sed-piped grep is region-scoped — must NOT warn, got: ${JSON.stringify(result.warnings)}`, - ); - }); - - // Case 24: awk region → NO warn - test('case 24 — awk region pipe: ! awk \'/^def make_page/,/^def /\' app/page.py | grep -Eq PAT + sibling → NO warn', () => { - const content = makeTwoTaskPlan({ - taskAGate: "! awk '/^def make_page/,/^def /' app/page.py | grep -Eq 'await .*refresh'", - taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', - }); - const result = scan(content); - assert.strictEqual( - result.warnings.filter(w => w.includes('#968')).length, - 0, - `awk-piped grep is region-scoped — must NOT warn, got: ${JSON.stringify(result.warnings)}`, - ); - }); - - // Case 25: basename non-over-match — different dirs, same basename → NO warn - test('case 25 — basename non-over-match: different dirs same filename → NO warn', () => { - // Task A bans on apps/web/config.py; Task B lists apps/admin/config.py - // Same basename "config.py" but different dirs → must NOT warn - const content = makeTwoTaskPlan({ - taskAFile: 'apps/web/config.py', - taskAGate: "! grep -Eq 'await .*refresh' apps/web/config.py", - taskBFile: 'apps/admin/config.py', - taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', - }); - const result = scan(content); - assert.strictEqual( - result.warnings.filter(w => w.includes('#968')).length, - 0, - `different dirs (apps/web/config.py vs apps/admin/config.py) — same basename but must NOT warn, got: ${JSON.stringify(result.warnings)}`, - ); - }); - - // Case 26: extensionless known file (Dockerfile) recognized via knownFiles → WARN - test('case 26 — extensionless known file (Dockerfile) via knownFiles → WARN', () => { - // Task A has ! grep -Eq 'FROM scratch' Dockerfile - // Dockerfile has no extension, so looksLikePath would miss it — but knownFiles should catch it - // Task B lists Dockerfile in and action requires 'FROM scratch' - const content = makeTwoTaskPlan({ - taskAFile: 'Dockerfile', - taskAGate: "! grep -Eq 'FROM scratch' Dockerfile", - taskBFile: 'Dockerfile', - taskBAction: 'Update the image base: FROM scratch ensures minimal surface area.', - }); - const result = scan(content); - assert.ok( - result.warnings.filter(w => w.includes('#968')).length >= 1, - `Dockerfile (extensionless, known via ) should be recognized — must warn, got: ${JSON.stringify(result.warnings)}`, - ); - assert.ok(result.valid !== false, 'valid must remain true'); - }); - - // Case 27: wildcard semantic match — patternRequiredIn handles .* correctly - test('case 27 — wildcard semantic match: "await .*refresh" (gate) warns when action has "await bridge.refresh()"', () => { - const content = makeTwoTaskPlan({ - taskAGate: "! grep -Eq 'await .*refresh' app/page.py", - taskBAction: 'Add handler that calls await bridge.refresh() to repopulate state.', - }); - const result = scan(content); - assert.ok( - result.warnings.filter(w => w.includes('#968')).length >= 1, - `patternRequiredIn must match "await .*refresh" against "await bridge.refresh()" — must warn, got: ${JSON.stringify(result.warnings)}`, - ); - assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true'); - }); - - // Case 4b: same-file positive control — sibling lists the SAME banned file + requires PAT → WARN - // Paired with case 4: proves the no-warn in case 4 is due to the file mismatch, not a dead detector. - test('case 4b — same-file positive control: sibling lists same file → WARN (proves case 4 no-warn is file-mismatch)', () => { - const content = makeTwoTaskPlan({ - taskAFile: 'app/page.py', - taskAGate: "! grep -Eq 'await .*refresh' app/page.py", - taskBFile: 'app/page.py', - taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', - }); - const result = scan(content); - assert.ok( - result.warnings.filter(w => w.includes('#968')).length >= 1, - `same-file sibling must warn — proves case 4's no-warn is due to file mismatch, got: ${JSON.stringify(result.warnings)}`, - ); - assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true'); - }); - - // Case 7b: non-inverted positive control — without -v the ban IS detected → WARN - // Paired with case 7: proves the -v skip is what suppresses case 7. - test('case 7b — non-inverted positive control: ! grep -q (no -v) + sibling → WARN (proves case 7 no-warn is -v skip)', () => { - const content = makeTwoTaskPlan({ - taskAGate: "! grep -q 'await .*refresh' app/page.py", - taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', - }); - const result = scan(content); - assert.ok( - result.warnings.filter(w => w.includes('#968')).length >= 1, - `non-inverted ! grep -q must warn — proves the -v flag is what suppresses case 7, got: ${JSON.stringify(result.warnings)}`, - ); - assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true'); - }); - - // Case 25b: basename-fallback positive — bare unqualified filename matches sibling's qualified path → WARN - // Paired with case 25: proves the bare-name basename fallback at src ~line 525 actually fires. - // Case 25 only proves qualified paths don't over-match; this proves the bare fallback does fire. - test('case 25b — basename-fallback positive: bare gate file matches sibling qualified path → WARN (proves basename fallback fires)', () => { - // Task A gate uses bare "config.py" (no directory prefix — unqualified). - // Task B lists "apps/admin/config.py" (qualified). basename("apps/admin/config.py") === "config.py". - // The basename fallback (line 525) should match → WARN. - const content = makeTwoTaskPlan({ - taskAFile: 'config.py', - taskAGate: "! grep -Eq 'await .*refresh' config.py", - taskBFile: 'apps/admin/config.py', - taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', - }); - const result = scan(content); - assert.ok( - result.warnings.filter(w => w.includes('#968')).length >= 1, - `bare gate file "config.py" must match sibling "apps/admin/config.py" via basename fallback — must warn, got: ${JSON.stringify(result.warnings)}`, - ); - assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true'); - }); - - // Case 28: anchored pattern warns after ^ strip — proves anchor stripping works - // Gate: ! grep -Eq '^FROM scratch' Dockerfile - // Sibling B lists Dockerfile, action requires 'FROM scratch' (no anchor in prose). - // Without anchor stripping, "^FROM scratch" would be treated as containing metacharacters - // and fall back to literal-substring: "^FROM scratch" not in B's prose → no warn. - // With anchor stripping, "FROM scratch" is the effective literal → found in B's prose → WARN. - test('case 28 — anchored pattern warns: ! grep -Eq \'^FROM scratch\' Dockerfile + sibling → WARN (proves ^ strip)', () => { - const content = makeTwoTaskPlan({ - taskAFile: 'Dockerfile', - taskAGate: "! grep -Eq '^FROM scratch' Dockerfile", - taskBFile: 'Dockerfile', - taskBAction: 'Update the image base: FROM scratch ensures minimal surface area.', - }); - const result = scan(content); - assert.ok( - result.warnings.filter(w => w.includes('#968')).length >= 1, - `anchored pattern "^FROM scratch" must warn after ^ is stripped — "FROM scratch" is in sibling action, got: ${JSON.stringify(result.warnings)}`, - ); - assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true'); - }); - - // Case 29: alternation falls back conservatively — documents the known limitation. - // Gate: ! grep -Eq 'debug|trace' src/logger.ts - // Sibling B lists src/logger.ts, action says "remove debug calls" (contains "debug" but NOT "debug|trace"). - // patternRequiredIn sees unhandled `|` in joined frags → literal-substring fallback on raw pattern. - // "debug|trace" is NOT literally in B's prose → conservative NO warn. - // This is intentional: false-negative is the safe direction for a warn-only advisory. - test('case 29 — alternation conservative fallback: "debug|trace" → NO warn (documents alternation limitation)', () => { - // NOTE: This is intended conservative behavior, not a bug. - // patternRequiredIn falls back to literal-substring for patterns containing `|` (alternation), - // because safely expanding alternation without new RegExp would require a mini-parser. - // The literal "debug|trace" is not present verbatim in the action, so no warn fires. - // A planner who writes `debug|trace` gets no advisory — acceptable, since a false-negative - // is always safer than a false-positive for a warn-only gate. - const content = makeTwoTaskPlan({ - taskAFile: 'src/logger.ts', - taskAGate: "! grep -Eq 'debug|trace' src/logger.ts", - taskBFile: 'src/logger.ts', - taskBAction: 'Remove debug calls from the logger module to reduce noise.', - }); - const result = scan(content); - assert.strictEqual( - result.warnings.filter(w => w.includes('#968')).length, - 0, - `alternation pattern "debug|trace" must conservatively NOT warn — literal "debug|trace" not in action, got: ${JSON.stringify(result.warnings)}`, - ); - }); - - // Case 18: empty content → no crash, no #968 warn - test('case 18 — empty content → no crash', () => { - let result; - assert.doesNotThrow(() => { - result = scan(''); - }); - assert.ok(Array.isArray(result.warnings), 'must return { warnings: [] }'); - assert.strictEqual( - result.warnings.filter(w => w.includes('#968')).length, - 0, - 'empty content must produce no #968 warn', - ); - }); - - // Case 18b: no-task plan → no crash - test('case 18b — no-task plan → no crash', () => { - const content = [ - '---', - 'phase: 01-test', - 'plan: 01', - 'type: execute', - 'wave: 1', - 'depends_on: []', - 'files_modified: []', - 'autonomous: true', - 'must_haves:', - ' - AC1', - '---', - '', - '# No tasks here', - ].join('\n'); - let result; - assert.doesNotThrow(() => { - result = scan(content); - }); - assert.strictEqual(result.warnings.filter(w => w.includes('#968')).length, 0); - }); -}); - -// ─── Group 2: end-to-end via runGsdTools ────────────────────────────────────── - -describe('scanFileWideNegativeGateConflict — end-to-end via verify plan-structure', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempProject(); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - // Case 19: integration — valid stays true despite warning (warn-only) - test('case 19 — integration: valid===true despite #968 warning', () => { - const planContent = makeTwoTaskPlan({ - taskAGate: "! grep -Eq 'await .*refresh' app/page.py", - taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', - }); - const planDir = path.join(tmpDir, '.planning', 'phases', '01-test'); - fs.mkdirSync(planDir, { recursive: true }); - fs.writeFileSync(path.join(planDir, '01-01-PLAN.md'), planContent); - - const result = runGsdTools( - 'verify plan-structure .planning/phases/01-test/01-01-PLAN.md', - tmpDir, - ); - const parsed = JSON.parse(result.output); - assert.strictEqual( - parsed.valid, - true, - `#968 is warn-only: valid must be true, got: ${JSON.stringify(parsed)}`, - ); - assert.ok( - parsed.warnings.some(w => w.includes('#968')), - `must have a #968 warning, got: ${JSON.stringify(parsed.warnings)}`, - ); - }); -}); - -// ─── Group 3: doc-contract ──────────────────────────────────────────────────── - -describe('doc-contract: guidance prose is in place', () => { - // Case 20: gsd-planner.md has the new guidance - test('case 20 — gsd-planner.md contains Region-scoped negative gates + reference', () => { - const content = fs.readFileSync(PLANNER_MD, 'utf8'); - assert.ok( - content.includes('Region-scoped negative gates'), - 'gsd-planner.md must include "Region-scoped negative gates"', - ); - assert.ok( - content.includes('planner-antipatterns.md'), - 'gsd-planner.md must reference planner-antipatterns.md', - ); - }); - - // Case 21: planner-antipatterns.md has the new section - test('case 21 — planner-antipatterns.md has ## Region-Scoped Negative Gates + examples', () => { - const content = fs.readFileSync(ANTIPATTERNS_MD, 'utf8'); - assert.ok( - content.includes('## Region-Scoped Negative Gates'), - 'planner-antipatterns.md must include "## Region-Scoped Negative Gates"', - ); - assert.ok( - content.includes('await .*refresh'), - 'planner-antipatterns.md must include the worked example pattern "await .*refresh"', - ); - // Verify sed or awk region example is present - const hasSedOrAwk = content.includes('sed -n') || content.includes('awk '); - assert.ok( - hasSedOrAwk, - 'planner-antipatterns.md must include sed-n or awk region example', - ); - }); -}); - -// ─── Group 4: AC3 executable proof ─────────────────────────────────────────── - -describe('AC3: executable proof — file-wide ban vs region-scoped simultaneously satisfiable', () => { - test('case 22 — grep/sed proof: both gates simultaneously satisfiable', () => { - // Check if grep and sed are available - const grepAvail = spawnSync('grep', ['--version']).status === 0; - const sedAvail = spawnSync('sed', ['--version']).status === 0 || - spawnSync('sed', ['-n', '1p', '/dev/null']).status === 0; - - if (!grepAvail || !sedAvail) { - // Skip gracefully if tools are unavailable - return; - } - - // Write a temp Python file with: - // def make_page(): — no await refresh - // async def reindex_handler(): — awaits bridge.refresh() - const tmpFile = path.join(os.tmpdir(), `gsd-968-proof-${process.pid}.py`); - const pyContent = [ - 'def make_page():', - ' """Synchronous factory — must not block on a refresh."""', - ' return {"title": "My Page"}', - '', - '', - 'async def reindex_handler():', - ' """Post-reindex callback — must await bridge.refresh() to repopulate state."""', - ' await bridge.refresh()', - ' return True', - ].join('\n'); - fs.writeFileSync(tmpFile, pyContent); - - try { - // (a) File-wide: grep -Eq 'await .*refresh' — should EXIT 0 (pattern found) - // This means a file-wide ban (! grep -Eq ...) WOULD FAIL - const fileWide = spawnSync('grep', ['-Eq', 'await .*refresh', tmpFile]); - assert.strictEqual( - fileWide.status, - 0, - 'grep file-wide should find the pattern (exits 0) — proving the file-wide ban would fail', - ); - - // (b) Region-scoped (make_page only): sed extracts lines 1-3, piped to grep → pattern NOT found - // The factory region is clean: ban PASSES - const makePageLines = spawnSync('sed', ['-n', '1,3p', tmpFile]); - assert.strictEqual(makePageLines.status, 0, 'sed should succeed'); - const makePageRegion = makePageLines.stdout.toString(); - - // Write to a temp file and grep it - const regionFile = path.join(os.tmpdir(), `gsd-968-region-${process.pid}.py`); - fs.writeFileSync(regionFile, makePageRegion); - try { - const regionBan = spawnSync('grep', ['-Eq', 'await .*refresh', regionFile]); - assert.strictEqual( - regionBan.status, - 1, - 'grep in make_page region should NOT find pattern (exits 1) — ban PASSES in factory region', - ); - - // (c) Region-scoped (reindex_handler): grep should FIND the pattern → requirement met - const reindexLines = spawnSync('sed', ['-n', '6,9p', tmpFile]); - const reindexRegion = reindexLines.stdout.toString(); - const reindexFile = path.join(os.tmpdir(), `gsd-968-reindex-${process.pid}.py`); - fs.writeFileSync(reindexFile, reindexRegion); - try { - const reindexCheck = spawnSync('grep', ['-Eq', 'await .*refresh', reindexFile]); - assert.strictEqual( - reindexCheck.status, - 0, - 'grep in reindex_handler region MUST find pattern (exits 0) — requirement met', - ); - } finally { - try { fs.unlinkSync(reindexFile); } catch { /* ignore */ } - } - } finally { - try { fs.unlinkSync(regionFile); } catch { /* ignore */ } - } - } finally { - try { fs.unlinkSync(tmpFile); } catch { /* ignore */ } - } - }); -}); diff --git a/tests/feat-1173-agent-converters-descriptor.test.cjs b/tests/feat-1173-agent-converters-descriptor.test.cjs deleted file mode 100644 index 970b608fc..000000000 --- a/tests/feat-1173-agent-converters-descriptor.test.cjs +++ /dev/null @@ -1,342 +0,0 @@ -'use strict'; - -/** - * feat-1173: Descriptor-driven agent converter wiring. - * - * Verifies that the descriptor-driven install path (dispatchKindEntry) applies - * per-runtime agent conversion when the 'agents' kind entry has a non-null - * converter — instead of silently raw-copying. - * - * Behavioral assertions: invoke the staging/dispatch seam, inspect the staged - * output files. NOT source-grep. - * - * TDD flow (REGRESSION-MUST-FAIL-FIRST rule): - * Before the fix, dispatchKindEntry ignores the converter for agents kind and - * raw-copies. The tests below prove conversion is applied by asserting that - * the staged .md contains runtime-specific frontmatter transformations absent - * in the raw source. - */ - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const os = require('node:os'); - -const ROOT = path.join(__dirname, '..'); - -const { - resolveRuntimeArtifactLayoutFromRegistry, -} = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-artifact-layout.cjs')); - -const { - cleanupStagedSkills, -} = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'install-profiles.cjs')); - -const { cleanup } = require('./helpers.cjs'); - -// ─── fixtures ──────────────────────────────────────────────────────────────── - -/** - * Minimal Claude agent source with comma-separated tools (Claude format). - * Copilot conversion turns tools into a JSON array (CONV-04/05). - * Codex conversion adds block. - * Cursor/Windsurf/Augment/Trae/Codebuddy/Cline conversion strips color field. - */ -const CLAUDE_AGENT_SOURCE = `--- -name: gsd-planner -description: A GSD planning agent. -tools: Bash, Read, Write -color: blue ---- - -# GSD Planner - -This agent plans GSD phases using ~/.claude/skills. -`; - -/** - * Create a temp agents directory with a .gsd-source marker pointing back to it - * (so findAgentsSourceRoot finds the fixture dir, not the real agents/ dir). - * The .gsd-source convention expects the marker to point at a commands/gsd dir; - * agents/ is resolved as a sibling of commands/. So we set up: - * / - * commands/gsd/ (empty, satisfies the sibling check) - * agents/ - * gsd-planner.md - * .gsd-source <- points to /commands/gsd - */ -function makeFixtureRoot(agentFiles) { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1173-root-')); - const commandsDir = path.join(root, 'commands', 'gsd'); - const agentsDir = path.join(root, 'agents'); - fs.mkdirSync(commandsDir, { recursive: true }); - fs.mkdirSync(agentsDir, { recursive: true }); - // .gsd-source marker must point to commands/gsd so that agentsSourceRoot resolves to agents/ - fs.writeFileSync(path.join(root, '.gsd-source'), commandsDir + '\n', 'utf8'); - for (const { name, content } of agentFiles) { - fs.writeFileSync(path.join(agentsDir, name), content, 'utf8'); - } - return root; -} - -function makeSyntheticRegistry(converterName) { - return { - runtimes: { - testruntime: { - runtime: { - artifactLayout: { - global: [ - { - kind: 'agents', - destSubpath: 'agents', - prefix: 'gsd-', - nesting: 'flat', - recursive: false, - converter: converterName, - }, - ], - local: [], - }, - }, - }, - }, - }; -} - -// ─── stageAgentsForRuntimeWithConverter unit tests ──────────────────────────── - -describe('feat-1173: stageAgentsForRuntimeWithConverter', () => { - test('is exported from install-profiles', () => { - const installProfiles = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'install-profiles.cjs')); - assert.strictEqual( - typeof installProfiles.stageAgentsForRuntimeWithConverter, - 'function', - 'stageAgentsForRuntimeWithConverter must be exported', - ); - }); - - test('applies converter to each staged agent file', (t) => { - const { stageAgentsForRuntimeWithConverter } = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'install-profiles.cjs')); - - const agentsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1173-agents-')); - fs.writeFileSync(path.join(agentsDir, 'gsd-planner.md'), CLAUDE_AGENT_SOURCE, 'utf8'); - fs.writeFileSync(path.join(agentsDir, 'gsd-executor.md'), CLAUDE_AGENT_SOURCE.replace('gsd-planner', 'gsd-executor'), 'utf8'); - - t.after(() => { - cleanup(agentsDir); - cleanupStagedSkills(); - }); - - const calls = []; - const converter = (content) => { - calls.push(content); - return content.replace('~/.claude/', '~/.copilot/'); - }; - - const resolvedProfile = { name: 'full', skills: '*', agents: new Set() }; - const stagedDir = stageAgentsForRuntimeWithConverter(agentsDir, resolvedProfile, converter); - - assert.strictEqual(calls.length, 2, 'converter called for each agent file'); - const stagedFiles = fs.readdirSync(stagedDir).sort(); - assert.deepStrictEqual(stagedFiles, ['gsd-executor.md', 'gsd-planner.md']); - - // Converter replaced ~/.claude/ with ~/.copilot/ in all staged files - for (const file of stagedFiles) { - const content = fs.readFileSync(path.join(stagedDir, file), 'utf8'); - assert.ok(!content.includes('~/.claude/'), `${file}: converter must have replaced ~/.claude/`); - assert.ok(content.includes('~/.copilot/'), `${file}: converter must have injected ~/.copilot/`); - } - }); - - test('non-existent srcAgentsDir returns srcAgentsDir unchanged', () => { - const { stageAgentsForRuntimeWithConverter } = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'install-profiles.cjs')); - const ghost = path.join(os.tmpdir(), 'gsd-1173-no-exist-' + Date.now()); - const converter = (c) => c; - const result = stageAgentsForRuntimeWithConverter(ghost, { name: 'full', skills: '*', agents: new Set() }, converter); - assert.strictEqual(result, ghost, 'must return srcAgentsDir unchanged for non-existent dir'); - }); - - test('only copies .md files (ignores non-.md)', (t) => { - const { stageAgentsForRuntimeWithConverter } = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'install-profiles.cjs')); - const agentsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1173-agents-')); - fs.writeFileSync(path.join(agentsDir, 'gsd-planner.md'), CLAUDE_AGENT_SOURCE, 'utf8'); - fs.writeFileSync(path.join(agentsDir, 'README.txt'), 'not an agent', 'utf8'); - - t.after(() => { - cleanup(agentsDir); - cleanupStagedSkills(); - }); - - const converter = (c) => c; - const resolvedProfile = { name: 'full', skills: '*', agents: new Set() }; - const stagedDir = stageAgentsForRuntimeWithConverter(agentsDir, resolvedProfile, converter); - const stagedFiles = fs.readdirSync(stagedDir); - assert.deepStrictEqual(stagedFiles, ['gsd-planner.md'], 'only .md files should be staged'); - }); -}); - -// ─── dispatchKindEntry wiring tests ────────────────────────────────────────── - -describe('feat-1173: dispatchKindEntry agents converter wiring', () => { - test('agents kind with convertClaudeAgentToCopilotAgent converter applies copilot conversion', (t) => { - const fixtureRoot = makeFixtureRoot([{ name: 'gsd-planner.md', content: CLAUDE_AGENT_SOURCE }]); - t.after(() => { - cleanup(fixtureRoot); - cleanupStagedSkills(); - }); - - const registry = makeSyntheticRegistry('convertClaudeAgentToCopilotAgent'); - const layout = resolveRuntimeArtifactLayoutFromRegistry( - registry, 'testruntime', fixtureRoot, 'global', - ); - - assert.strictEqual(layout.kinds.length, 1); - const agentKind = layout.kinds[0]; - assert.strictEqual(agentKind.kind, 'agents'); - - const resolvedProfile = { name: 'full', skills: '*', agents: new Set() }; - const stagedDir = agentKind.stage(resolvedProfile); - - const stagedFile = path.join(stagedDir, 'gsd-planner.md'); - assert.ok(fs.existsSync(stagedFile), `staged file must exist: ${stagedFile}`); - - const stagedContent = fs.readFileSync(stagedFile, 'utf8'); - - // Copilot CONV-04/05: tools converted from "Bash, Read, Write" to JSON array "['bash', 'read', 'write']" - // Raw copy would keep the original comma-separated "tools: Bash, Read, Write" line. - assert.notStrictEqual(stagedContent, CLAUDE_AGENT_SOURCE, 'converter must have transformed the content'); - assert.ok( - stagedContent.includes("tools: ['") || stagedContent.includes('tools: ['), - `Copilot conversion must produce JSON array tools. Got:\n${stagedContent.slice(0, 300)}`, - ); - }); - - test('agents kind with convertClaudeAgentToCodexAgent converter applies codex conversion', (t) => { - const fixtureRoot = makeFixtureRoot([{ name: 'gsd-planner.md', content: CLAUDE_AGENT_SOURCE }]); - t.after(() => { - cleanup(fixtureRoot); - cleanupStagedSkills(); - }); - - const registry = makeSyntheticRegistry('convertClaudeAgentToCodexAgent'); - const layout = resolveRuntimeArtifactLayoutFromRegistry( - registry, 'testruntime', fixtureRoot, 'global', - ); - - const agentKind = layout.kinds[0]; - const resolvedProfile = { name: 'full', skills: '*', agents: new Set() }; - const stagedDir = agentKind.stage(resolvedProfile); - - const stagedContent = fs.readFileSync(path.join(stagedDir, 'gsd-planner.md'), 'utf8'); - - // Codex conversion adds block - assert.notStrictEqual(stagedContent, CLAUDE_AGENT_SOURCE, 'converter must have transformed the content'); - assert.ok( - stagedContent.includes(''), - `Codex conversion must add . Got:\n${stagedContent.slice(0, 300)}`, - ); - }); - - test('agents kind with convertClaudeAgentToCursorAgent converter applies cursor conversion', (t) => { - const fixtureRoot = makeFixtureRoot([{ name: 'gsd-planner.md', content: CLAUDE_AGENT_SOURCE }]); - t.after(() => { - cleanup(fixtureRoot); - cleanupStagedSkills(); - }); - - const registry = makeSyntheticRegistry('convertClaudeAgentToCursorAgent'); - const layout = resolveRuntimeArtifactLayoutFromRegistry( - registry, 'testruntime', fixtureRoot, 'global', - ); - - const agentKind = layout.kinds[0]; - const resolvedProfile = { name: 'full', skills: '*', agents: new Set() }; - const stagedDir = agentKind.stage(resolvedProfile); - - const stagedContent = fs.readFileSync(path.join(stagedDir, 'gsd-planner.md'), 'utf8'); - - // Cursor conversion strips color field and rewrites ~/.claude/ paths - assert.notStrictEqual(stagedContent, CLAUDE_AGENT_SOURCE, 'converter must have transformed the content'); - assert.ok( - !stagedContent.includes('color:'), - `Cursor agent conversion should strip the color field. Got:\n${stagedContent.slice(0, 300)}`, - ); - }); - - test('agents kind with converter=null still raw-copies (backward compat for claude)', (t) => { - const fixtureRoot = makeFixtureRoot([{ name: 'gsd-planner.md', content: CLAUDE_AGENT_SOURCE }]); - t.after(() => { - cleanup(fixtureRoot); - cleanupStagedSkills(); - }); - - const registry = makeSyntheticRegistry(null); - const layout = resolveRuntimeArtifactLayoutFromRegistry( - registry, 'testruntime', fixtureRoot, 'global', - ); - - const agentKind = layout.kinds[0]; - const resolvedProfile = { name: 'full', skills: '*', agents: new Set() }; - const stagedDir = agentKind.stage(resolvedProfile); - - // converter=null: stageAgentsForProfile with skills='*' returns srcAgentsDir unchanged - // (no staging dir is created; the source dir IS the staged dir, a passthrough) - const stagedContent = fs.readFileSync(path.join(stagedDir, 'gsd-planner.md'), 'utf8'); - assert.strictEqual(stagedContent, CLAUDE_AGENT_SOURCE, 'converter=null must raw-copy the agent content'); - }); - - test('scope threads isGlobal to a scope-aware converter (global vs local differ)', (t) => { - // The plumbing kept by #1173 (option a): convertedAgentsKind / dispatchKindEntry - // pass the install scope to the converter as isGlobal. A scope-aware converter - // (copilot) must therefore produce different output for global vs local. This - // proves the thread is live via a synthetic descriptor — no real runtime - // declares a converted agents kind yet (declarations deferred to the ADR-1235 - // §0 parity follow-up). - const fixtureRoot = makeFixtureRoot([{ name: 'gsd-planner.md', content: CLAUDE_AGENT_SOURCE }]); - t.after(() => { - cleanup(fixtureRoot); - cleanupStagedSkills(); - }); - - const agentsEntry = { - kind: 'agents', - destSubpath: 'agents', - prefix: 'gsd-', - nesting: 'flat', - recursive: false, - converter: 'convertClaudeAgentToCopilotAgent', - }; - const registry = { - runtimes: { testruntime: { runtime: { artifactLayout: { global: [agentsEntry], local: [agentsEntry] } } } }, - }; - - const profile = { name: 'full', skills: '*', agents: new Set() }; - const stageFor = (scope) => { - const layout = resolveRuntimeArtifactLayoutFromRegistry(registry, 'testruntime', fixtureRoot, scope); - const agentKind = layout.kinds.find((k) => k.kind === 'agents'); - assert.ok(agentKind, `${scope} layout must include an agents kind`); - return fs.readFileSync(path.join(agentKind.stage(profile), 'gsd-planner.md'), 'utf8'); - }; - - const globalOut = stageFor('global'); - const localOut = stageFor('local'); - assert.notStrictEqual( - globalOut, - localOut, - 'scope-aware converter output must differ by scope — proves isGlobal is threaded from the descriptor scope', - ); - }); -}); - -// ─── real registry: claude agents kind has converter=null ──────────────────── - -describe('feat-1173: real registry claude agents kind has converter=null (backward compat)', () => { - test('claude local artifacts layout has agents entry with converter=null', () => { - const registry = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'capability-registry.cjs')); - const claudeDesc = registry.runtimes?.claude?.runtime?.artifactLayout?.local ?? []; - const agentsEntry = claudeDesc.find((e) => e.kind === 'agents'); - assert.ok(agentsEntry, 'claude local artifactLayout must have an agents entry'); - assert.strictEqual(agentsEntry.converter, null, 'claude agents entry must have converter=null'); - }); -}); diff --git a/tests/feat-1452-context-guard-mode.test.cjs b/tests/feat-1452-context-guard-mode.test.cjs deleted file mode 100644 index 7d19d4619..000000000 --- a/tests/feat-1452-context-guard-mode.test.cjs +++ /dev/null @@ -1,213 +0,0 @@ -// allow-test-rule: source-text-is-the-product see #1452 -// The execute-phase.md workflow and context-budget.md reference ARE the runtime -// contract loaded by AI runtimes. Asserting that the canonical wording for -// `workflow.context_guard_mode` is present in those files is the only way to -// verify runtimes will respect the flag at runtime. - -/** - * Enhancement #1452: workflow.context_guard_mode - * - * Guards long execute-phase workflows from driving the host session to context - * exhaustion (ctx 100%). Before each wave, the orchestrator self-assesses - * context pressure using the degradation signals defined in context-budget.md. - * - * Modes: - * "warn" (default) — emit a structured warning + recommend /gsd:pause-work - * "auto" — auto-invoke pause-work before the next wave - * "off" — disable the guard entirely - * - * The check fires at wave boundaries ONLY (before spawning), never mid-wave. - */ - -'use strict'; - -const { test, describe, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); -const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); - -function readConfig(tmpDir) { - const configPath = path.join(tmpDir, '.planning', 'config.json'); - return JSON.parse(fs.readFileSync(configPath, 'utf-8')); -} - -const REPO_ROOT = path.join(__dirname, '..'); - -// ─── Schema registration ────────────────────────────────────────────────────── - -describe('workflow.context_guard_mode in VALID_CONFIG_KEYS', () => { - test('is a recognized config key', () => { - const { VALID_CONFIG_KEYS } = require('../gsd-core/bin/lib/config.cjs'); - assert.ok( - VALID_CONFIG_KEYS.has('workflow.context_guard_mode'), - 'workflow.context_guard_mode should be in VALID_CONFIG_KEYS', - ); - }); -}); - -// ─── Default value ──────────────────────────────────────────────────────────── - -describe('workflow.context_guard_mode default value', () => { - let tmpDir; - beforeEach(() => { tmpDir = createTempProject(); }); - afterEach(() => { cleanup(tmpDir); }); - - test('defaults to warn in new project config', () => { - const result = runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir }); - assert.ok(result.success, `config-ensure-section failed: ${result.error}`); - - const config = readConfig(tmpDir); - assert.strictEqual( - config.workflow.context_guard_mode, - 'warn', - 'workflow.context_guard_mode should default to "warn" — proactive checkpoint warning without auto-pausing workflows', - ); - }); -}); - -// ─── Round-trip ────────────────────────────────────────────────────────────── - -describe('workflow.context_guard_mode config round-trip', () => { - let tmpDir; - beforeEach(() => { - tmpDir = createTempProject(); - runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir }); - }); - afterEach(() => { cleanup(tmpDir); }); - - test('config-set warn persists to config.json', () => { - const setResult = runGsdTools('config-set workflow.context_guard_mode warn', tmpDir); - assert.ok(setResult.success, `config-set failed: ${setResult.error}`); - - const config = readConfig(tmpDir); - assert.strictEqual(config.workflow.context_guard_mode, 'warn'); - }); - - test('config-set auto persists to config.json', () => { - const setResult = runGsdTools('config-set workflow.context_guard_mode auto', tmpDir); - assert.ok(setResult.success, `config-set failed: ${setResult.error}`); - - const config = readConfig(tmpDir); - assert.strictEqual(config.workflow.context_guard_mode, 'auto'); - }); - - test('config-set off persists to config.json', () => { - const setResult = runGsdTools('config-set workflow.context_guard_mode off', tmpDir); - assert.ok(setResult.success, `config-set failed: ${setResult.error}`); - - const config = readConfig(tmpDir); - assert.strictEqual(config.workflow.context_guard_mode, 'off'); - }); - - test('persists in config.json as string', () => { - runGsdTools('config-set workflow.context_guard_mode warn', tmpDir); - - const config = readConfig(tmpDir); - assert.strictEqual(config.workflow.context_guard_mode, 'warn'); - assert.strictEqual(typeof config.workflow.context_guard_mode, 'string'); - }); - - test('rejects unknown mode values with clear error', () => { - const result = runGsdTools('config-set workflow.context_guard_mode aggressive', tmpDir); - assert.strictEqual(result.success, false); - assert.match(result.error, /Invalid workflow\.context_guard_mode 'aggressive'/); - assert.match(result.error, /auto, warn, off/); - }); - - test('rejects partial match values', () => { - const result = runGsdTools('config-set workflow.context_guard_mode warnmode', tmpDir); - assert.strictEqual(result.success, false); - assert.match(result.error, /Invalid workflow\.context_guard_mode 'warnmode'/); - }); -}); - -// ─── execute-phase contract ─────────────────────────────────────────────────── - -describe('execute-phase.md documents the context_guard step', () => { - let executePhase; - let contextGuardRef; - - beforeEach(() => { - executePhase = fs.readFileSync( - path.join(REPO_ROOT, 'gsd-core', 'workflows', 'execute-phase.md'), - 'utf-8', - ); - // The step body is extracted to a reference file loaded via @-ref in execute-phase.md. - // Both files together constitute the execute-phase wave-boundary contract. - const refPath = path.join(REPO_ROOT, 'gsd-core', 'references', 'execute-phase-context-guard.md'); - contextGuardRef = fs.existsSync(refPath) ? fs.readFileSync(refPath, 'utf-8') : ''; - }); - - test('references workflow.context_guard_mode by canonical name', () => { - const combined = executePhase + '\n' + contextGuardRef; - assert.ok( - combined.includes('workflow.context_guard_mode'), - 'execute-phase.md (or its @-referenced execute-phase-context-guard.md) must reference workflow.context_guard_mode so runtimes resolve the config-driven behavior', - ); - }); - - test('defines context_guard step at wave boundaries', () => { - assert.ok( - executePhase.includes('context_guard') || executePhase.includes('context-guard'), - 'execute-phase.md must define a context_guard step (or @-ref to it) that fires before each wave', - ); - }); - - test('references context-budget.md tiers in the guard step', () => { - const combined = executePhase + '\n' + contextGuardRef; - assert.ok( - combined.includes('context-budget') || combined.includes('POOR') || combined.includes('DEGRADING'), - 'execute-phase.md context_guard (or its @-referenced file) must reference context-budget.md degradation tiers', - ); - }); -}); - -// ─── context-budget.md contract ────────────────────────────────────────────── - -describe('context-budget.md documents POOR-tier trigger action', () => { - let contextBudget; - - beforeEach(() => { - contextBudget = fs.readFileSync( - path.join(REPO_ROOT, 'gsd-core', 'references', 'context-budget.md'), - 'utf-8', - ); - }); - - test('defines POOR tier', () => { - assert.ok( - contextBudget.includes('POOR'), - 'context-budget.md must define the POOR tier', - ); - }); - - test('connects POOR tier to pause-work', () => { - assert.ok( - contextBudget.includes('pause-work') || contextBudget.includes('pause_work'), - 'context-budget.md POOR-tier rule must reference pause-work as the trigger action', - ); - }); - - test('documents context_guard_mode values', () => { - assert.ok( - contextBudget.includes('context_guard_mode'), - 'context-budget.md must document the workflow.context_guard_mode config key', - ); - }); -}); - -// ─── planning-config.md reference parity ───────────────────────────────────── - -describe('planning-config.md documents workflow.context_guard_mode', () => { - test('includes the key in the reference table', () => { - const planningConfig = fs.readFileSync( - path.join(REPO_ROOT, 'gsd-core', 'references', 'planning-config.md'), - 'utf-8', - ); - assert.ok( - planningConfig.includes('workflow.context_guard_mode'), - 'planning-config.md reference must include workflow.context_guard_mode so users know the config knob exists', - ); - }); -}); diff --git a/tests/feat-1754-cli-skew-detection.test.cjs b/tests/feat-1754-cli-skew-detection.test.cjs deleted file mode 100644 index 7792b518f..000000000 --- a/tests/feat-1754-cli-skew-detection.test.cjs +++ /dev/null @@ -1,85 +0,0 @@ -'use strict'; - -/** - * feat-1754-cli-skew-detection.test.cjs - * - * Tests for the CLI version-skew detection module (src/cli-skew-check.cts). - * - * The check warns (returns a string) when the running gsd-tools.cjs is NOT the - * project-local install while a project-local install EXISTS — the shadowing - * scenario from #1748 (a stale global canary from @gsd-build/sdk shadowing - * project-local 1.6.0). - * - * DEFECT class: environment / version skew (enhancement #1754) - * - * The function is PURE (no I/O — the caller provides paths + existence flags), - * making it trivially testable without filesystem setup. - */ - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const path = require('node:path'); - -const { checkCliSkew } = require('../gsd-core/bin/lib/cli-skew-check.cjs'); - -describe('#1754: checkCliSkew — pure path-comparison skew detection', () => { - test('SKEW: resolved CLI outside project root + project-local exists → returns warning', () => { - const warning = checkCliSkew({ - resolvedPath: '/opt/homebrew/bin/gsd-tools', - projectRoot: '/home/user/my-project', - projectLocalExists: true, - }); - assert.ok(warning, 'Expected a warning string when resolved CLI is outside project root and project-local exists'); - assert.ok(warning.includes('shadow') || warning.includes('outside') || warning.includes('may'), - `Warning should mention the shadowing/outside nature, got: "${warning}"`); - }); - - test('NO-SKEW: resolved CLI is the project-local install → returns null', () => { - const warning = checkCliSkew({ - resolvedPath: '/home/user/my-project/.claude/gsd-core/bin/gsd-tools.cjs', - projectRoot: '/home/user/my-project', - projectLocalExists: true, - }); - assert.strictEqual(warning, null, 'No warning expected when resolved CLI IS the project-local install'); - }); - - test('NO-SKEW: resolved CLI outside project root but NO project-local install → returns null', () => { - const warning = checkCliSkew({ - resolvedPath: '/usr/local/bin/gsd-tools', - projectRoot: '/home/user/my-project', - projectLocalExists: false, - }); - assert.strictEqual(warning, null, 'No warning expected when no project-local install exists (legitimate global-only)'); - }); - - test('NO-SKEW: projectRoot is null (no project context) → returns null', () => { - const warning = checkCliSkew({ - resolvedPath: '/usr/local/bin/gsd-tools', - projectRoot: null, - projectLocalExists: false, - }); - assert.strictEqual(warning, null, 'No warning expected when there is no project root'); - }); - - test('LEGACY-SDK: resolved path contains @gsd-build → warning includes removal instructions', () => { - const warning = checkCliSkew({ - resolvedPath: '/opt/homebrew/lib/node_modules/@gsd-build/sdk/bin/gsd-tools', - projectRoot: '/home/user/my-project', - projectLocalExists: true, - }); - assert.ok(warning, 'Expected a warning for @gsd-build/sdk paths'); - assert.ok(warning.includes('@gsd-build/sdk') || warning.includes('npm uninstall'), - `Warning should include @gsd-build/sdk removal instructions, got: "${warning}"`); - }); - - test('PATH-NORMALIZATION: resolved under project root via realpath → no false positive', () => { - // Even if the resolved path differs in symlink resolution, if it's under the - // project root, it's not a skew. The caller normalizes paths before calling. - const warning = checkCliSkew({ - resolvedPath: path.resolve('/home/user/my-project/.claude/gsd-core/bin/gsd-tools.cjs'), - projectRoot: path.resolve('/home/user/my-project'), - projectLocalExists: true, - }); - assert.strictEqual(warning, null, 'No warning when resolved path is under project root (even with realpath normalization)'); - }); -}); diff --git a/tests/feat-2527-settings-layers.test.cjs b/tests/feat-2527-settings-layers.test.cjs deleted file mode 100644 index a6022217c..000000000 --- a/tests/feat-2527-settings-layers.test.cjs +++ /dev/null @@ -1,245 +0,0 @@ -'use strict'; - -/** - * Feature test for #2527 — /gsd-settings expands to 22 settings grouped into - * six visual sections. Adds 8 new fields (pattern_mapper, tdd_mode, code_review, - * code_review_depth, ui_review, commit_docs, intel.enabled, graphify.enabled) - * and verifies each is present in the AskUserQuestion block, the update_config - * step, the confirmation table, the ~/.gsd/defaults.json save step, and the - * effective config-key validator. - * - * Closes: #2527 - */ - -const { describe, test, before } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); -const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); - -const SETTINGS_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'settings.md'); -const { - VALID_CONFIG_KEYS, - isCentralConfigKey, - isValidConfigKey, -} = require('../gsd-core/bin/lib/config-schema.cjs'); - -const NEW_FIELDS = [ - 'workflow.pattern_mapper', - 'workflow.tdd_mode', - 'workflow.code_review', - 'workflow.code_review_depth', - 'workflow.ui_review', - 'commit_docs', - 'intel.enabled', - 'graphify.enabled', -]; - -const CENTRAL_NEW_FIELDS = [ - 'commit_docs', -]; - -const CAPABILITY_OWNED_NEW_FIELDS = NEW_FIELDS.filter((field) => !CENTRAL_NEW_FIELDS.includes(field)); - -const SECTION_HEADERS = ['Planning', 'Execution', 'Docs & Output', 'Features', 'Model & Pipeline', 'Misc']; - -/** - * Match a dotted config-key path inside a block of text. Falls back to a - * simple substring check for single-segment keys; for nested keys, requires - * each segment to appear in order within a bounded window so distinct fields - * (e.g., intel.enabled vs graphify.enabled) cannot collapse to the same leaf. - */ -function hasPathLike(block, field) { - const parts = field.split('.'); - if (parts.length === 1) return block.includes(parts[0]); - const escaped = parts.map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); - const pattern = new RegExp(escaped.join('[\\s\\S]{0,600}'), 'i'); - return pattern.test(block); -} - -describe('#2527: settings.md adds grouped settings layers', () => { - let content; - - before(() => { - content = fs.readFileSync(SETTINGS_PATH, 'utf-8'); - }); - - describe('Acceptance: all 8 new fields present in AskUserQuestion block', () => { - for (const field of NEW_FIELDS) { - test(`settings.md mentions ${field}`, () => { - assert.ok( - content.includes(field), - `settings.md must reference the config key "${field}" in its AskUserQuestion/update_config step` - ); - }); - } - }); - - describe('Acceptance: section headers applied', () => { - for (const section of SECTION_HEADERS) { - test(`settings.md declares a "${section}" section header`, () => { - // The convention for grouping AskUserQuestion items is a markdown section heading - // of the form "###
" inside the present_settings step. - const heading = new RegExp(`^#{2,4}\\s+${section.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}\\b`, 'm'); - assert.ok( - heading.test(content), - `settings.md must declare a "${section}" section header to group questions` - ); - }); - } - }); - - describe('Acceptance: update_config step includes all new fields', () => { - test('update_config step references every new field', () => { - const updateMatch = content.match(/[\s\S]*?<\/step>/); - assert.ok(updateMatch, 'settings.md must have an update_config step'); - const updateBlock = updateMatch[0]; - for (const field of NEW_FIELDS) { - // Keys may appear as nested JSON (e.g., "pattern_mapper" under workflow). - // Use hasPathLike so distinct dotted keys (e.g., intel.enabled, - // graphify.enabled) cannot share a single "enabled" occurrence. - assert.ok( - hasPathLike(updateBlock, field), - `update_config step must write "${field}"` - ); - } - }); - }); - - describe('Acceptance: save_as_defaults step includes all new fields', () => { - test('save_as_defaults step references every new field', () => { - const defaultsMatch = content.match(/[\s\S]*?<\/step>/); - assert.ok(defaultsMatch, 'settings.md must have a save_as_defaults step'); - const block = defaultsMatch[0]; - for (const field of NEW_FIELDS) { - assert.ok( - hasPathLike(block, field), - `save_as_defaults step must persist "${field}" into ~/.gsd/defaults.json` - ); - } - }); - }); - - describe('Acceptance: confirmation display includes all new fields', () => { - test('confirm step table lists every new setting by name', () => { - const confirmMatch = content.match(/[\s\S]*?<\/step>/); - assert.ok(confirmMatch, 'settings.md must have a confirm step'); - const block = confirmMatch[0]; - const expectedLabels = [ - 'Pattern Mapper', - 'TDD Mode', - 'Code Review', - 'Code Review Depth', - 'UI Review', - 'Commit Docs', - 'Intel', - 'Graphify', - ]; - for (const label of expectedLabels) { - assert.ok( - block.includes(label), - `confirm step table must display "${label}"` - ); - } - }); - }); - - describe('Acceptance: all 8 new fields accepted by the config validator', () => { - for (const field of NEW_FIELDS) { - test(`config validator accepts ${field}`, () => { - assert.ok( - isValidConfigKey(field), - `${field} must be accepted so config-set can write it` - ); - }); - } - }); - - describe('Acceptance: migrated capability fields are no longer central config keys', () => { - for (const field of CAPABILITY_OWNED_NEW_FIELDS) { - test(`${field} is capability-owned, not central-schema residue`, () => { - assert.equal( - isCentralConfigKey(field), - false, - `${field} must be owned by the capability registry instead of the central schema` - ); - assert.equal( - VALID_CONFIG_KEYS.has(field), - false, - `${field} must not be duplicated in VALID_CONFIG_KEYS after Phase 6 migration` - ); - }); - } - }); - - describe('Acceptance: still-central settings remain in VALID_CONFIG_KEYS', () => { - for (const field of CENTRAL_NEW_FIELDS) { - test(`VALID_CONFIG_KEYS contains central setting ${field}`, () => { - assert.ok( - VALID_CONFIG_KEYS.has(field), - `${field} is not a migrated capability key and must remain in VALID_CONFIG_KEYS` - ); - }); - } - }); - - describe('Acceptance: code_review_depth is conditional on code_review=on', () => { - test('settings.md documents conditional visibility for code_review_depth', () => { - // Must explicitly note that code_review_depth only appears when code_review is on. - const conditionalRegex = /code_review_depth[\s\S]{0,400}(only|conditional|when|if)[\s\S]{0,80}code_review/i; - assert.ok( - conditionalRegex.test(content) || - /code_review\s*=\s*on[\s\S]{0,400}code_[…]*depth/i.test(content), - 'settings.md must document that code_review_depth is only shown when code_review is on' - ); - }); - }); - - describe('Negative: settings.md constrains code_review_depth options', () => { - test('settings.md restricts code_review_depth to a known option set', () => { - // Depth accepts string values (quick|standard|deep). config-set does not - // block arbitrary strings at the value level today; instead settings.md - // constrains the AskUserQuestion options to the valid set so users - // cannot pick "bogus" via the interactive flow. - const depthOptionsRegex = - /code_review_depth[\s\S]{0,800}(quick|standard|deep|surface)/i; - assert.ok( - depthOptionsRegex.test(content), - 'settings.md must constrain code_review_depth options to a known set' - ); - }); - }); - - describe('Negative: config-set rejects an unknown key path', () => { - test('config-set workflow.code_review_bogus_key fails', (t) => { - const tmpDir = createTempProject(); - t.after(() => cleanup(tmpDir)); - - const bad = runGsdTools(['config-set', 'workflow.code_review_bogus_key', 'x'], tmpDir); - assert.ok(!bad.success, 'config-set on an unknown key must fail'); - }); - }); - - describe('Acceptance: all 6 section headers are used as header: field on first question in each section', () => { - test('the header field appears for each section in the AskUserQuestion block', () => { - // Map user-visible section names to the short `header:` strings used in AskUserQuestion. - // settings.md uses abbreviated headers (max 12 chars). Verify at least one header - // per section-intent appears on a question. - const requiredHeaders = [ - /header:\s*"Model"/, // Model & Pipeline opener - /header:\s*"Research"/, // Planning opener (first Planning-section question) - /header:\s*"Pattern Mapper"|header:\s*"Patterns"/, // new Planning addition - /header:\s*"Verifier"/, // Execution existing - /header:\s*"TDD"/, // new Execution - /header:\s*"Code Review"/, // new Execution - /header:\s*"UI Review"/, // new Execution - /header:\s*"Commit Docs"/, // new Docs & Output - /header:\s*"Intel"/, // new Features - /header:\s*"Graphify"/, // new Features - ]; - for (const re of requiredHeaders) { - assert.ok(re.test(content), `settings.md must include an AskUserQuestion header matching ${re}`); - } - }); - }); -}); diff --git a/tests/feat-3024-dynamic-routing.test.cjs b/tests/feat-3024-dynamic-routing.test.cjs deleted file mode 100644 index 31a1be68f..000000000 --- a/tests/feat-3024-dynamic-routing.test.cjs +++ /dev/null @@ -1,363 +0,0 @@ -/** - * Feature test for issue #3024 — dynamic routing with failure-tier escalation. - * - * Adds a `dynamic_routing` block to .planning/config.json: - * - * { - * "dynamic_routing": { - * "enabled": true, - * "tier_models": { - * "light": "haiku", - * "standard": "sonnet", - * "heavy": "opus" - * }, - * "escalate_on_failure": true, - * "max_escalations": 1 - * } - * } - * - * Each agent has a default tier (light/standard/heavy). When dynamic - * routing is enabled, the resolver picks `tier_models[default_tier]` - * for the first attempt. On orchestrator-detected soft failure, the - * orchestrator calls the resolver again with `attempt: 1`, which - * returns the next tier up (capped at `max_escalations`). - * - * This PR delivers the JS-layer infrastructure: schema + tier map + - * resolver + escalation helpers. Orchestrator adoption is incremental - * follow-up — this PR's contract is the resolver function and the - * config it consumes. - * - * Resolution precedence (highest → lowest): - * 1. model_overrides[agent] (full IDs accepted; targeted) - * 2. dynamic_routing.tier_models[tier] (NEW; escalation-aware) - * 3. models[phase_type] (#3023; coarse phase-level) - * 4. model_profile (per-agent column) - * 5. Runtime default - * - * Tests are typed-IR / structural — assert on the value returned by - * resolveModelForTier or isValidConfigKey, not stdout/grep. - */ - -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -const { test, describe, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const { - resolveModelInternal, - resolveModelForTier, -} = require('../gsd-core/bin/lib/model-resolver.cjs'); -const { - AGENT_DEFAULT_TIERS, - VALID_AGENT_TIERS, - MODEL_PROFILES, - nextTier, -} = require('../gsd-core/bin/lib/model-profiles.cjs'); -const { isValidConfigKey } = require('../gsd-core/bin/lib/config-schema.cjs'); - -const { createTempDir, cleanup } = require('./helpers.cjs'); -const makeTmp = (prefix) => createTempDir(`gsd-3024-${prefix}-`); -function writeConfig(dir, config) { - const planningDir = path.join(dir, '.planning'); - fs.mkdirSync(planningDir, { recursive: true }); - fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify(config, null, 2)); -} -function rmr(p) { cleanup(p); } - -// ─── Schema: AGENT_DEFAULT_TIERS coverage + valid tier set ────────────────── - -describe('#3024 schema: every agent has a default tier (light/standard/heavy)', () => { - test('AGENT_DEFAULT_TIERS exported as a non-empty object', () => { - assert.equal(typeof AGENT_DEFAULT_TIERS, 'object'); - assert.ok(AGENT_DEFAULT_TIERS !== null); - assert.ok(Object.keys(AGENT_DEFAULT_TIERS).length > 0); - }); - - test('VALID_AGENT_TIERS exposes exactly {light, standard, heavy}', () => { - assert.deepStrictEqual([...VALID_AGENT_TIERS].sort(), ['heavy', 'light', 'standard']); - }); - - test('every agent in MODEL_PROFILES has a default tier', () => { - const missing = Object.keys(MODEL_PROFILES).filter((a) => !AGENT_DEFAULT_TIERS[a]); - assert.deepStrictEqual(missing, []); - }); - - test('every assigned tier is one of the three valid tiers', () => { - const invalid = Object.entries(AGENT_DEFAULT_TIERS).filter( - ([, t]) => !VALID_AGENT_TIERS.has(t) - ); - assert.deepStrictEqual(invalid, []); - }); -}); - -// ─── nextTier helper ──────────────────────────────────────────────────────── - -describe('#3024 nextTier helper', () => { - test('exported as a function', () => { - assert.equal(typeof nextTier, 'function'); - }); - - test('light → standard → heavy → heavy (caps at heavy)', () => { - assert.equal(nextTier('light'), 'standard'); - assert.equal(nextTier('standard'), 'heavy'); - assert.equal(nextTier('heavy'), 'heavy', 'already at top — stays at heavy'); - }); - - test('returns null for invalid input', () => { - assert.equal(nextTier('jumbo'), null); - assert.equal(nextTier(null), null); - assert.equal(nextTier(undefined), null); - }); -}); - -// ─── Resolver behavior: dynamic routing, disabled mode ────────────────────── - -describe('#3024 resolveModelForTier: disabled mode is a no-op (acceptance criterion 1)', () => { - let projectDir; - beforeEach(() => { projectDir = makeTmp('disabled'); }); - afterEach(() => { rmr(projectDir); }); - - test('exported as a function', () => { - assert.equal(typeof resolveModelForTier, 'function'); - }); - - test('with no dynamic_routing block, falls back to resolveModelInternal', () => { - writeConfig(projectDir, { model_profile: 'balanced' }); - // resolveModelForTier with attempt=0 must match resolveModelInternal. - const baseline = resolveModelInternal(projectDir, 'gsd-phase-researcher'); - assert.equal(resolveModelForTier(projectDir, 'gsd-phase-researcher', 0), baseline); - }); - - test('with dynamic_routing.enabled=false, attempt argument is ignored — same as resolveModelInternal', () => { - writeConfig(projectDir, { - model_profile: 'balanced', - dynamic_routing: { - enabled: false, - tier_models: { light: 'haiku', standard: 'sonnet', heavy: 'opus' }, - }, - }); - const baseline = resolveModelInternal(projectDir, 'gsd-phase-researcher'); - // attempt=0 and attempt=1 both ignored when disabled - assert.equal(resolveModelForTier(projectDir, 'gsd-phase-researcher', 0), baseline); - assert.equal(resolveModelForTier(projectDir, 'gsd-phase-researcher', 1), baseline); - }); -}); - -// ─── Resolver behavior: dynamic routing, enabled ──────────────────────────── - -describe('#3024 resolveModelForTier: enabled mode picks tier_models[default_tier]', () => { - let projectDir; - beforeEach(() => { projectDir = makeTmp('enabled'); }); - afterEach(() => { rmr(projectDir); }); - - test('attempt=0 returns tier_models[agent_default_tier] (acceptance criterion 2)', () => { - writeConfig(projectDir, { - model_profile: 'balanced', - dynamic_routing: { - enabled: true, - tier_models: { light: 'haiku', standard: 'sonnet', heavy: 'opus' }, - }, - }); - // gsd-codebase-mapper has light default tier per AGENT_DEFAULT_TIERS. - // CR nitpick (#3031): assert preconditions explicitly so a tier - // re-mapping in AGENT_DEFAULT_TIERS surfaces as a test failure - // instead of a silent skip. - assert.equal(AGENT_DEFAULT_TIERS['gsd-codebase-mapper'], 'light', - 'gsd-codebase-mapper expected to be light tier'); - assert.equal(resolveModelForTier(projectDir, 'gsd-codebase-mapper', 0), 'haiku'); - assert.equal(AGENT_DEFAULT_TIERS['gsd-planner'], 'heavy', - 'gsd-planner expected to be heavy tier'); - assert.equal(resolveModelForTier(projectDir, 'gsd-planner', 0), 'opus'); - }); - - test('attempt=1 escalates to next tier up (acceptance criterion 3)', () => { - writeConfig(projectDir, { - model_profile: 'balanced', - dynamic_routing: { - enabled: true, - tier_models: { light: 'haiku', standard: 'sonnet', heavy: 'opus' }, - escalate_on_failure: true, - max_escalations: 1, - }, - }); - // For an agent with default tier 'light', attempt=1 should give 'standard' tier model. - const lightAgent = Object.entries(AGENT_DEFAULT_TIERS).find(([, t]) => t === 'light')?.[0]; - assert.ok(lightAgent, 'AGENT_DEFAULT_TIERS must contain at least one light agent'); - assert.equal(resolveModelForTier(projectDir, lightAgent, 0), 'haiku'); - assert.equal(resolveModelForTier(projectDir, lightAgent, 1), 'sonnet'); - // For a 'standard' agent, attempt=1 should give 'heavy' model. - const stdAgent = Object.entries(AGENT_DEFAULT_TIERS).find(([, t]) => t === 'standard')?.[0]; - assert.ok(stdAgent, 'AGENT_DEFAULT_TIERS must contain at least one standard agent'); - assert.equal(resolveModelForTier(projectDir, stdAgent, 0), 'sonnet'); - assert.equal(resolveModelForTier(projectDir, stdAgent, 1), 'opus'); - }); - - test('attempts beyond max_escalations cap at the highest reachable tier (acceptance criterion 4)', () => { - writeConfig(projectDir, { - model_profile: 'balanced', - dynamic_routing: { - enabled: true, - tier_models: { light: 'haiku', standard: 'sonnet', heavy: 'opus' }, - escalate_on_failure: true, - max_escalations: 1, // cap at 1 escalation total - }, - }); - const lightAgent = Object.entries(AGENT_DEFAULT_TIERS).find(([, t]) => t === 'light')?.[0]; - assert.ok(lightAgent, 'AGENT_DEFAULT_TIERS must contain at least one light agent'); - // attempts beyond max_escalations should not exceed max_escalations' - // tier — i.e. attempt=2 with max=1 = same as attempt=1. - assert.equal(resolveModelForTier(projectDir, lightAgent, 2), 'sonnet', - 'attempt=2 with max_escalations=1 caps at attempt=1 tier'); - assert.equal(resolveModelForTier(projectDir, lightAgent, 5), 'sonnet'); - }); - - test('"heavy" agents stay at heavy (no tier above)', () => { - writeConfig(projectDir, { - model_profile: 'balanced', - dynamic_routing: { - enabled: true, - tier_models: { light: 'haiku', standard: 'sonnet', heavy: 'opus' }, - escalate_on_failure: true, - max_escalations: 2, - }, - }); - const heavyAgent = Object.entries(AGENT_DEFAULT_TIERS).find(([, t]) => t === 'heavy')?.[0]; - assert.ok(heavyAgent, 'AGENT_DEFAULT_TIERS must contain at least one heavy agent'); - assert.equal(resolveModelForTier(projectDir, heavyAgent, 0), 'opus'); - // Already at heavy — escalation cannot go higher. - assert.equal(resolveModelForTier(projectDir, heavyAgent, 1), 'opus'); - assert.equal(resolveModelForTier(projectDir, heavyAgent, 5), 'opus'); - }); - - test('default max_escalations is 1 when omitted', () => { - writeConfig(projectDir, { - model_profile: 'balanced', - dynamic_routing: { - enabled: true, - tier_models: { light: 'haiku', standard: 'sonnet', heavy: 'opus' }, - // max_escalations omitted — default to 1 - }, - }); - const lightAgent = Object.entries(AGENT_DEFAULT_TIERS).find(([, t]) => t === 'light')?.[0]; - assert.ok(lightAgent, 'AGENT_DEFAULT_TIERS must contain at least one light agent'); - // attempt=1 escalates; attempt=2 should cap at attempt=1 (default max=1) - assert.equal(resolveModelForTier(projectDir, lightAgent, 1), 'sonnet'); - assert.equal(resolveModelForTier(projectDir, lightAgent, 2), 'sonnet'); - }); - - // ─── CR Major (#3031): escalate_on_failure: false honored ────────────── - - test('escalate_on_failure:false disables escalation even when attempt > 0 (CR Major)', () => { - // Pre-fix bug: an orchestrator that always passes attempt+1 on retry - // would silently escalate even though the user opted out via - // escalate_on_failure:false. The kill-switch must short-circuit - // every attempt back to the default tier. - writeConfig(projectDir, { - model_profile: 'balanced', - dynamic_routing: { - enabled: true, - tier_models: { light: 'haiku', standard: 'sonnet', heavy: 'opus' }, - escalate_on_failure: false, // ← kill-switch - max_escalations: 5, - }, - }); - const lightAgent = Object.entries(AGENT_DEFAULT_TIERS).find(([, t]) => t === 'light')?.[0]; - assert.ok(lightAgent, 'AGENT_DEFAULT_TIERS must contain at least one light agent'); - // Every attempt must resolve to the default (light → haiku), - // regardless of how high the orchestrator bumped the counter. - assert.equal(resolveModelForTier(projectDir, lightAgent, 0), 'haiku'); - assert.equal(resolveModelForTier(projectDir, lightAgent, 1), 'haiku', - 'escalate_on_failure:false must not escalate even at attempt=1'); - assert.equal(resolveModelForTier(projectDir, lightAgent, 5), 'haiku'); - }); - - test('escalate_on_failure:true (explicit) escalates normally', () => { - // Sanity: explicit true matches the default truthy behavior. - writeConfig(projectDir, { - model_profile: 'balanced', - dynamic_routing: { - enabled: true, - tier_models: { light: 'haiku', standard: 'sonnet', heavy: 'opus' }, - escalate_on_failure: true, - max_escalations: 1, - }, - }); - const lightAgent = Object.entries(AGENT_DEFAULT_TIERS).find(([, t]) => t === 'light')?.[0]; - assert.ok(lightAgent); - assert.equal(resolveModelForTier(projectDir, lightAgent, 1), 'sonnet'); - }); -}); - -// ─── Resolver precedence ──────────────────────────────────────────────────── - -describe('#3024 precedence: per-agent override > dynamic_routing > models > profile', () => { - let projectDir; - beforeEach(() => { projectDir = makeTmp('precedence'); }); - afterEach(() => { rmr(projectDir); }); - - test('per-agent model_overrides beats dynamic_routing (acceptance criterion: override wins)', () => { - writeConfig(projectDir, { - model_profile: 'balanced', - model_overrides: { 'gsd-codebase-mapper': 'openai/gpt-5' }, - dynamic_routing: { - enabled: true, - tier_models: { light: 'haiku', standard: 'sonnet', heavy: 'opus' }, - }, - }); - // Per-agent override always wins, even at escalated attempt. - assert.equal(resolveModelForTier(projectDir, 'gsd-codebase-mapper', 0), 'openai/gpt-5'); - assert.equal(resolveModelForTier(projectDir, 'gsd-codebase-mapper', 1), 'openai/gpt-5'); - }); - - test('dynamic_routing beats phase-type models (#3023)', () => { - writeConfig(projectDir, { - model_profile: 'balanced', - models: { research: 'opus' }, // phase-type would say opus - dynamic_routing: { - enabled: true, - tier_models: { light: 'haiku', standard: 'sonnet', heavy: 'opus' }, - }, - }); - // gsd-codebase-mapper is research phase-type; phase-type would give 'opus', - // but dynamic routing (light default → haiku) wins. - if (AGENT_DEFAULT_TIERS['gsd-codebase-mapper'] === 'light') { - assert.equal(resolveModelForTier(projectDir, 'gsd-codebase-mapper', 0), 'haiku'); - } - }); -}); - -// ─── Schema validation ────────────────────────────────────────────────────── - -describe('#3024 config-schema: dynamic_routing.* validation', () => { - test('dynamic_routing.enabled is a valid config key', () => { - assert.equal(isValidConfigKey('dynamic_routing.enabled'), true); - }); - - test('dynamic_routing.escalate_on_failure is a valid config key', () => { - assert.equal(isValidConfigKey('dynamic_routing.escalate_on_failure'), true); - }); - - test('dynamic_routing.max_escalations is a valid config key', () => { - assert.equal(isValidConfigKey('dynamic_routing.max_escalations'), true); - }); - - test('dynamic_routing.tier_models. for each valid tier', () => { - for (const t of ['light', 'standard', 'heavy']) { - assert.equal(isValidConfigKey(`dynamic_routing.tier_models.${t}`), true); - } - }); - - test('unknown tier in tier_models is rejected', () => { - assert.equal(isValidConfigKey('dynamic_routing.tier_models.jumbo'), false); - assert.equal(isValidConfigKey('dynamic_routing.tier_models.medium'), false); - }); - - test('unknown dynamic_routing.* keys are rejected', () => { - assert.equal(isValidConfigKey('dynamic_routing.foo'), false); - assert.equal(isValidConfigKey('dynamic_routing'), false, - 'bare dynamic_routing (no field) must not be a config-set target'); - }); -}); diff --git a/tests/feat-3210-fallow-integration.test.cjs b/tests/feat-3210-fallow-integration.test.cjs deleted file mode 100644 index 59ea3e4dd..000000000 --- a/tests/feat-3210-fallow-integration.test.cjs +++ /dev/null @@ -1,407 +0,0 @@ -// allow-test-rule: source-text-is-the-product -// This test validates workflow/agent/config contracts stored in shipped .md/.ts/.cjs -// artifacts. Source text is the runtime product for those surfaces. - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const os = require('node:os'); -const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs'); - -const ROOT = path.resolve(__dirname, '..'); - -// N2: single helper — on macOS os.tmpdir() already returns /private/tmp; the -// existsSync guard is kept only as defense-in-depth fallback. -function getWritableTmp() { - const candidates = ['/private/tmp', '/tmp', os.tmpdir()]; - return candidates.find((dir) => { - try { - fs.accessSync(dir, fs.constants.W_OK); - return true; - } catch { - return false; - } - }); -} - -describe('feat-3210: fallow integration module', () => { - test('normalizes structural findings from a fallow report', () => { - const { normalizeFallowReport } = require('../gsd-core/bin/lib/fallow-runner.cjs'); - const fixture = JSON.parse( - fs.readFileSync(path.join(ROOT, 'tests', 'fixtures', 'fallow', 'sample-findings.json'), 'utf8'), - ); - - const normalized = normalizeFallowReport(fixture); - // Counts derived from real schema fixture fields - const expectedUnused = fixture.dead_code.unused_exports.length; - const expectedUnusedFiles = fixture.dead_code.unused_files.length; - const expectedCircular = fixture.dead_code.circular_dependencies.length; - const expectedDuplicates = fixture.duplication.clone_groups.length; - assert.deepStrictEqual(normalized.summary, { - unused_exports: expectedUnused, - unused_files: expectedUnusedFiles, - duplicates: expectedDuplicates, - circular_dependencies: expectedCircular, - total: 4, - }); - assert.strictEqual(normalized.findings.length, 4); - }); - - test('falls back to node_modules/.bin/fallow when PATH does not contain fallow', () => { - const { resolveFallowBinary } = require('../gsd-core/bin/lib/fallow-runner.cjs'); - // N2: use shared helper - const baseTmp = getWritableTmp(); - const tmp = fs.mkdtempSync(path.join(baseTmp, 'gsd-fallow-bin-')); - const binDir = path.join(tmp, 'node_modules', '.bin'); - fs.mkdirSync(binDir, { recursive: true }); - const fallowPath = path.join(binDir, 'fallow'); - fs.writeFileSync(fallowPath, '#!/usr/bin/env sh\n'); - if (process.platform !== 'win32') fs.chmodSync(fallowPath, 0o755); - - const resolved = resolveFallowBinary({ cwd: tmp, envPath: '' }); - assert.strictEqual(resolved, fallowPath); - - cleanup(tmp); - }); - - // H6: replaced wholesale win32 skip with platform-adapted assertion - test('ignores non-executable PATH candidate on non-Windows; prefers .cmd over bare extensionless on Windows', () => { - const { resolveFallowBinary } = require('../gsd-core/bin/lib/fallow-runner.cjs'); - // N2: use shared helper - const baseTmp = getWritableTmp(); - - if (process.platform === 'win32') { - // H6: Windows — .cmd extension candidate must be preferred over bare extensionless file - const tmp = fs.mkdtempSync(path.join(baseTmp, 'gsd-fallow-win-')); - try { - const pathDir = path.join(tmp, 'bin'); - fs.mkdirSync(pathDir, { recursive: true }); - const bareFile = path.join(pathDir, 'fallow'); - const cmdFile = path.join(pathDir, 'fallow.cmd'); - fs.writeFileSync(bareFile, '@echo off\r\n'); - fs.writeFileSync(cmdFile, '@echo off\r\n'); - const resolved = resolveFallowBinary({ cwd: tmp, envPath: pathDir }); - assert.strictEqual( - resolved, - cmdFile, - 'Windows: .cmd candidate must be preferred over bare extensionless file', - ); - } finally { - cleanup(tmp); - } - } else { - // H6: non-Windows — non-executable file in PATH must be ignored - const tmp = fs.mkdtempSync(path.join(baseTmp, 'gsd-fallow-nonexec-')); - try { - const pathDir = path.join(tmp, 'bin'); - fs.mkdirSync(pathDir, { recursive: true }); - const nonExec = path.join(pathDir, 'fallow'); - fs.writeFileSync(nonExec, '#!/usr/bin/env sh\n'); - fs.chmodSync(nonExec, 0o644); - const resolved = resolveFallowBinary({ cwd: tmp, envPath: pathDir }); - assert.strictEqual(resolved, null); - } finally { - cleanup(tmp); - } - } - }); - - test('normalizes empty fallow report to zero findings', () => { - const { normalizeFallowReport } = require('../gsd-core/bin/lib/fallow-runner.cjs'); - const fixture = JSON.parse( - fs.readFileSync(path.join(ROOT, 'tests', 'fixtures', 'fallow', 'sample-empty.json'), 'utf8'), - ); - const normalized = normalizeFallowReport(fixture); - assert.deepStrictEqual(normalized.summary, { - unused_exports: 0, - unused_files: 0, - duplicates: 0, - circular_dependencies: 0, - total: 0, - }); - assert.deepStrictEqual(normalized.findings, []); - }); - - test('throws actionable error when fallow is enabled but binary is unavailable', () => { - const { requireFallowBinary } = require('../gsd-core/bin/lib/fallow-runner.cjs'); - // N2: use shared helper - const baseTmp = getWritableTmp(); - const tmp = fs.mkdtempSync(path.join(baseTmp, 'gsd-fallow-missing-')); - assert.throws( - () => requireFallowBinary({ cwd: tmp, envPath: '' }), - /install fallow via `npm install -D fallow` or `cargo install fallow`/, - ); - cleanup(tmp); - }); - - // M5: edge-case fixture — line:0 preservation, unicode path, single-instance clone_group, 3-file cycle - test('normalizes edge-case fixture: line:0 preservation, unicode path, single-instance clone_group, 3-file cycle', () => { - const { normalizeFallowReport } = require('../gsd-core/bin/lib/fallow-runner.cjs'); - const fixture = JSON.parse( - fs.readFileSync( - path.join(ROOT, 'tests', 'fixtures', 'fallow', 'sample-edge-cases.json'), - 'utf8', - ), - ); - - // Real schema: unused_export with line:0 — must survive without coercion - assert.strictEqual(fixture.dead_code.unused_exports.length, 1); - assert.strictEqual(fixture.dead_code.unused_exports[0].line, 0, 'edge-case fixture: line must be 0'); - // unicode file path is preserved in fixture - assert.ok( - fixture.dead_code.unused_exports[0].path.includes('café'), - 'edge-case fixture: unicode file path must be present', - ); - - // single-instance clone_group (related_file normalizes to '') - assert.strictEqual(fixture.duplication.clone_groups.length, 1); - assert.strictEqual(fixture.duplication.clone_groups[0].instances.length, 1); - - // 3-file circular dependency cycle - assert.strictEqual(fixture.dead_code.circular_dependencies.length, 1); - assert.strictEqual( - fixture.dead_code.circular_dependencies[0].files.length, - 3, - 'edge-case: files array must have exactly 3 entries', - ); - - // normalization round-trips without throwing - const normalized = normalizeFallowReport(fixture); - // 1 unused_export + 0 unused_files + 1 circular_dep + 1 clone_group = 3 - const expectedTotal = - fixture.dead_code.unused_exports.length + - fixture.dead_code.unused_files.length + - fixture.dead_code.circular_dependencies.length + - fixture.duplication.clone_groups.length; - assert.strictEqual(normalized.findings.length, expectedTotal); - assert.strictEqual(normalized.summary.total, expectedTotal); - - // line:0 survives normalization - const unicodeFinding = normalized.findings.find( - (f) => typeof f.file === 'string' && f.file.includes('café'), - ); - assert.ok(unicodeFinding, 'unicode file path must survive normalization round-trip'); - assert.strictEqual(unicodeFinding.line, 0, 'line:0 must not be coerced to null'); - - // single-instance clone_group: related_file must be '' - const dupFinding = normalized.findings.find((f) => f.type === 'duplicate_block'); - assert.ok(dupFinding, 'duplicate_block finding must exist'); - assert.strictEqual(dupFinding.related_file, '', 'single-instance clone_group: related_file must be empty string'); - }); -}); - -describe('feat-3210: H1 - line:0 preservation', () => { - test('normalizeFallowReport preserves line:0 for unused_export (not coerced to null)', () => { - const { normalizeFallowReport } = require('../gsd-core/bin/lib/fallow-runner.cjs'); - const report = { - dead_code: { - unused_exports: [{ path: 'src/a.ts', export_name: 'foo', line: 0 }], - }, - }; - const normalized = normalizeFallowReport(report); - assert.strictEqual(normalized.findings[0].line, 0, 'line:0 must not be coerced to null via ||'); - }); - - test('normalizeFallowReport preserves line:0 for duplicate_block instances[0].start_line (not coerced to null)', () => { - const { normalizeFallowReport } = require('../gsd-core/bin/lib/fallow-runner.cjs'); - const report = { - duplication: { - clone_groups: [ - { instances: [{ file: 'src/a.ts', start_line: 0 }, { file: 'src/b.ts', start_line: 5 }] }, - ], - }, - }; - const normalized = normalizeFallowReport(report); - assert.strictEqual(normalized.findings[0].line, 0, 'start_line:0 must not be coerced to null via ||'); - }); -}); - -describe('feat-3210: M2 - node_modules/.bin resolution order', () => { - test('resolveFallowBinary prefers node_modules/.bin over PATH when both exist', () => { - const { resolveFallowBinary } = require('../gsd-core/bin/lib/fallow-runner.cjs'); - // N2: use shared helper - const baseTmp = getWritableTmp(); - const tmp = fs.mkdtempSync(path.join(baseTmp, 'gsd-fallow-order-')); - try { - // local node_modules/.bin/fallow - const binDir = path.join(tmp, 'node_modules', '.bin'); - fs.mkdirSync(binDir, { recursive: true }); - const localFallow = path.join(binDir, 'fallow'); - fs.writeFileSync(localFallow, '#!/usr/bin/env sh\necho local\n'); - if (process.platform !== 'win32') fs.chmodSync(localFallow, 0o755); - - // PATH fallow (a different file) - const pathDir = path.join(tmp, 'pathbin'); - fs.mkdirSync(pathDir, { recursive: true }); - const pathFallow = path.join(pathDir, 'fallow'); - fs.writeFileSync(pathFallow, '#!/usr/bin/env sh\necho path\n'); - if (process.platform !== 'win32') fs.chmodSync(pathFallow, 0o755); - - const resolved = resolveFallowBinary({ cwd: tmp, envPath: pathDir }); - assert.strictEqual(resolved, localFallow, 'node_modules/.bin/fallow must win over PATH fallow'); - } finally { - cleanup(tmp); - } - }); -}); - -describe('feat-3210 / #1012: code-review workflow invokes fallow with the real CLI', () => { - // allow-test-rule: source-text-is-the-product — code-review.md IS the workflow the orchestrator - // executes; its fallow invocation is the product surface. - const workflowSrc = fs.readFileSync( - path.join(ROOT, 'gsd-core', 'workflows', 'code-review.md'), - 'utf8', - ); - - test('uses audit --format json and --quiet (real fallow 2.x flags)', () => { - assert.ok( - workflowSrc.includes('audit --format json'), - 'workflow must invoke: audit --format json', - ); - assert.ok( - workflowSrc.includes('--quiet'), - 'workflow must pass --quiet to suppress progress output', - ); - }); - - test('does NOT use removed flags: --json , --profile, --stdin-files', () => { - assert.ok( - !workflowSrc.includes('--json '), - 'workflow must not use old --json flag (note trailing space to avoid matching --format json)', - ); - assert.ok( - !workflowSrc.includes('--profile'), - 'workflow must not use --profile (fallow has no native profile concept)', - ); - assert.ok( - !workflowSrc.includes('--stdin-files'), - 'workflow must not use --stdin-files (removed in fallow 2.x)', - ); - }); - - test('uses --max-crap for threshold control (profile maps to max-crap)', () => { - assert.ok( - workflowSrc.includes('--max-crap'), - 'workflow must use --max-crap to control threshold (profile mapped to this flag)', - ); - }); - - test('scopes phase via --changed-since (native fallow git-ref scoping)', () => { - assert.ok( - workflowSrc.includes('--changed-since'), - 'workflow must use --changed-since for phase scoping', - ); - }); - - test('normalizes fallow output via normalizeFallowReportFile before embedding', () => { - assert.ok( - workflowSrc.includes('normalizeFallowReportFile'), - 'workflow must call normalizeFallowReportFile to normalize before embedding into reviewer prompt', - ); - }); - - test('exit-handling gates on valid JSON (verdict in o), not on exit code', () => { - assert.ok( - workflowSrc.includes("'verdict' in o"), - "workflow exit-handling must use 'verdict' in o to decide success (not exit code)", - ); - }); -}); - -describe('feat-3210: workflow and config contracts', () => { - test('config schema allows code_quality.fallow.* keys in CJS and runtime manifest', () => { - // CJS config-schema and runtime consume the same manifest source-of-truth. - // Use the CJS runtime Set and the manifest directly (no inline text parsing). - const { VALID_CONFIG_KEYS } = require('../gsd-core/bin/lib/config-schema.cjs'); - const manifestPath = path.join(ROOT, 'gsd-core', 'bin', 'shared', 'config-schema.manifest.json'); - const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); - const manifestKeys = new Set(manifest.validKeys); - for (const key of [ - 'code_quality.fallow.enabled', - 'code_quality.fallow.scope', - 'code_quality.fallow.profile', - 'code_quality.fallow.mcp', - ]) { - assert.ok(VALID_CONFIG_KEYS.has(key), `missing CJS config key: ${key}`); - assert.ok(manifestKeys.has(key), `missing manifest key: ${key} (runtime sources from manifest)`); - } - }); - - test('config-set accepts code_quality.fallow keys', () => { - const originalTmpDir = process.env.TMPDIR; - // L2: fail loudly if no writable tmp dir is found (was silent skip) - const writableTmp = getWritableTmp(); // N2: use shared helper - assert.ok(writableTmp, 'no writable tmp directory found'); // L2: explicit fail-loud assertion - process.env.TMPDIR = writableTmp; - const tmpDir = createTempProject('gsd-fallow-config-'); - try { - const cases = [ - ['code_quality.fallow.enabled', 'true'], - ['code_quality.fallow.scope', 'repo'], - ['code_quality.fallow.profile', 'strict'], - ['code_quality.fallow.mcp', 'false'], - ]; - for (const [key, value] of cases) { - const result = runGsdTools(['config-set', key, value], tmpDir); - assert.ok(result.success, `config-set failed for ${key}: ${result.error || result.output}`); - } - } finally { - cleanup(tmpDir); - if (originalTmpDir === undefined) delete process.env.TMPDIR; - else process.env.TMPDIR = originalTmpDir; - } - }); - - // B4: replaced 5x source-grep tautologies with parse-based structural checks. - // The workflow .md uses XML-like tags as its runtime DSL; we parse the step block - // structurally and assert on structural properties, not on prose strings. - test('code-review workflow structural_pre_pass step is parseable and references FALLOW.json output', () => { - const workflow = fs.readFileSync( - path.join(ROOT, 'gsd-core', 'workflows', 'code-review.md'), - 'utf8', - ); - - // Parse: the block must exist and be closed - const stepMatch = workflow.match(/([\s\S]*?)<\/step>/); - assert.ok( - stepMatch, - 'workflow must contain a parseable ... block', - ); - - const stepBody = stepMatch[1]; - - // Structural property: the step body must reference the FALLOW.json output artifact - assert.ok( - stepBody.includes('FALLOW.json'), - 'structural_pre_pass step body must reference the FALLOW.json output artifact', - ); - - // Structural property: the step body must gate on the fallow enabled config key - assert.ok( - stepBody.includes('code_quality.fallow.enabled'), - 'structural_pre_pass step body must gate on code_quality.fallow.enabled', - ); - }); - - // B4: agent output contract — doc-parity check (approved fallback per config-schema-docs-parity - // pattern). We confirm the heading exists in the shipped artifact, not in a live agent response. - // Live agent output is covered by /gsd-code-review e2e runs downstream. - test('reviewer prompt defines ## Structural Findings (fallow) heading and review context echoes it', () => { - const reviewer = fs.readFileSync(path.join(ROOT, 'agents', 'gsd-code-reviewer.md'), 'utf8'); - const reviewContext = fs.readFileSync(path.join(ROOT, 'gsd-core', 'contexts', 'review.md'), 'utf8'); - - // Doc-parity: section heading must exist in the shipped agent file (the heading is a contract, - // not prose — renaming it would break every consumer that parses agent output by section) - assert.ok( - reviewer.includes('## Structural Findings (fallow)'), - 'gsd-code-reviewer.md must define ## Structural Findings (fallow) section heading', - ); - - // Doc-parity: review context that agents receive must reference the same section - assert.ok( - reviewContext.includes('Structural Findings (fallow)'), - 'review.md context must reference Structural Findings (fallow) so agents recognize the section', - ); - }); -}); diff --git a/tests/feat-3251-command-aliases-manifest-coverage.test.cjs b/tests/feat-3251-command-aliases-manifest-coverage.test.cjs deleted file mode 100644 index e920ce790..000000000 --- a/tests/feat-3251-command-aliases-manifest-coverage.test.cjs +++ /dev/null @@ -1,278 +0,0 @@ -'use strict'; -/** - * Regression guard for issue #3251: - * 14 commands used in workflows must be present in command-aliases.cjs. - * - * Asserts structurally by requiring the manifest and checking each canonical - * command appears in either the family arrays or the non-family array. - * Never greps the source file — see feedback_no_source_grep_tests.md. - */ - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const crypto = require('node:crypto'); -const fs = require('node:fs'); -const os = require('node:os'); -const path = require('path'); -const { spawnSync } = require('node:child_process'); -const { cleanup } = require('./helpers.cjs'); - -const REPO_ROOT = path.join(__dirname, '..'); -const COMMAND_ALIASES_FILE = path.join( - REPO_ROOT, - 'gsd-core', - 'bin', - 'lib', - 'command-aliases.cjs', -); -const GSD_TOOLS = path.join(REPO_ROOT, 'gsd-core', 'bin', 'gsd-tools.cjs'); - -const MISSING_14 = [ - 'check.decision-coverage-plan', - 'check.decision-coverage-verify', - 'frontmatter.get', - 'frontmatter.set', - 'learnings.copy', - 'milestone.complete', - 'phase.mvp-mode', - 'progress.bar', - 'requirements.mark-complete', - 'stats.json', - 'task.is-behavior-adding', - 'todo.match-phase', - 'uat.render-checkpoint', - 'workstream.list', -]; - -describe('feat-3251: command-aliases.cjs manifest coverage', () => { - let manifest; - - test('manifest file can be required without error', () => { - try { - manifest = require(COMMAND_ALIASES_FILE); - } catch (err) { - assert.fail(`Failed to require manifest: ${err.message}`); - } - assert.ok(manifest, 'manifest should be truthy'); - }); - - test('manifest exports NON_FAMILY_COMMAND_ALIASES array', () => { - manifest = manifest ?? require(COMMAND_ALIASES_FILE); - assert.ok( - Array.isArray(manifest.NON_FAMILY_COMMAND_ALIASES), - 'NON_FAMILY_COMMAND_ALIASES must be an exported array in command-aliases.cjs', - ); - }); - - test('all 14 missing commands are present in the manifest (family or non-family)', () => { - manifest = manifest ?? require(COMMAND_ALIASES_FILE); - - const allCanonicalsInManifest = new Set(); - - // Collect from all family arrays - const familyArrayKeys = [ - 'STATE_COMMAND_ALIASES', - 'VERIFY_COMMAND_ALIASES', - 'INIT_COMMAND_ALIASES', - 'PHASE_COMMAND_ALIASES', - 'PHASES_COMMAND_ALIASES', - 'VALIDATE_COMMAND_ALIASES', - 'ROADMAP_COMMAND_ALIASES', - 'EVAL_COMMAND_ALIASES', - ]; - for (const key of familyArrayKeys) { - const arr = manifest[key]; - if (!Array.isArray(arr)) continue; - for (const entry of arr) { - if (entry && entry.canonical) allCanonicalsInManifest.add(entry.canonical); - } - } - - // Collect from non-family array - const nonFamily = manifest.NON_FAMILY_COMMAND_ALIASES; - if (Array.isArray(nonFamily)) { - for (const entry of nonFamily) { - if (entry && entry.canonical) allCanonicalsInManifest.add(entry.canonical); - } - } - - const missing = MISSING_14.filter((cmd) => !allCanonicalsInManifest.has(cmd)); - assert.deepStrictEqual( - missing, - [], - `${missing.length} command(s) still missing from manifest: ${missing.join(', ')}`, - ); - }); - - test('each non-family entry has required fields: canonical, aliases, mutation', () => { - manifest = manifest ?? require(COMMAND_ALIASES_FILE); - const nonFamily = manifest.NON_FAMILY_COMMAND_ALIASES; - if (!Array.isArray(nonFamily)) return; // caught by earlier test - - for (const entry of nonFamily) { - assert.ok(typeof entry.canonical === 'string' && entry.canonical.length > 0, - `entry missing canonical: ${JSON.stringify(entry)}`); - assert.ok(Array.isArray(entry.aliases), - `entry missing aliases array for canonical=${entry.canonical}`); - assert.ok(typeof entry.mutation === 'boolean', - `entry missing mutation boolean for canonical=${entry.canonical}`); - } - }); - - test('NON_FAMILY_COMMAND_ALIASES is sorted by canonical (deterministic output)', () => { - manifest = manifest ?? require(COMMAND_ALIASES_FILE); - const nonFamily = manifest.NON_FAMILY_COMMAND_ALIASES; - if (!Array.isArray(nonFamily)) return; // caught by earlier test - - const canonicals = nonFamily.map((e) => e.canonical); - const sorted = [...canonicals].sort((a, b) => a.localeCompare(b)); - assert.deepStrictEqual( - canonicals, - sorted, - 'NON_FAMILY_COMMAND_ALIASES must be sorted by canonical for deterministic regeneration', - ); - }); -}); - -function createProject() { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3251-dispatch-')); - fs.mkdirSync(path.join(dir, '.planning', 'phases'), { recursive: true }); - return dir; -} - -function runGsdTools(args, projectDir) { - return spawnSync(process.execPath, [GSD_TOOLS, ...args], { - cwd: projectDir, - encoding: 'utf8', - timeout: 30000, - killSignal: 'SIGKILL', - }); -} - -function snapshotProjectState(projectDir) { - const files = []; - function walk(dir) { - if (!fs.existsSync(dir)) return; - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, entry.name); - const rel = path.relative(projectDir, full); - if (entry.isDirectory()) walk(full); - else { - files.push({ - path: rel, - sha256: crypto.createHash('sha256').update(fs.readFileSync(full)).digest('hex'), - }); - } - } - } - walk(projectDir); - return files.sort((a, b) => a.path.localeCompare(b.path)); -} - -describe('feat-3251: generated aliases dispatch through real gsd-tools behavior', () => { - test('phase.mvp-mode spaced alias resolves CLI flag precedence', () => { - const projectDir = createProject(); - try { - const result = runGsdTools(['phase', 'mvp-mode', '1', '--cli-flag'], projectDir); - assert.equal(result.status, 0, result.stderr); - - const output = JSON.parse(result.stdout); - assert.deepEqual(output, { - active: true, - source: 'cli_flag', - roadmap_mode: null, - config_mvp_mode: false, - cli_flag_present: true, - }); - } finally { - cleanup(projectDir); - } - }); - - test('phase.mvp-mode spaced alias resolves ROADMAP mode without mutating files', () => { - const projectDir = createProject(); - try { - fs.writeFileSync( - path.join(projectDir, '.planning', 'ROADMAP.md'), - [ - '# Roadmap', - '', - '## v1.0.0', - '', - '### Phase 1: User Auth', - '**Goal:** Users can sign in.', - '**Mode:** mvp', - '', - ].join('\n'), - ); - const beforeFiles = snapshotProjectState(projectDir); - - const result = runGsdTools(['phase', 'mvp-mode', '1'], projectDir); - assert.equal(result.status, 0, result.stderr); - - const output = JSON.parse(result.stdout); - assert.equal(output.active, true); - assert.equal(output.source, 'roadmap'); - assert.equal(output.roadmap_mode, 'mvp'); - assert.equal(output.config_mvp_mode, false); - assert.equal(output.cli_flag_present, false); - assert.deepEqual(snapshotProjectState(projectDir), beforeFiles); - } finally { - cleanup(projectDir); - } - }); - - test('phase.mvp-mode ROADMAP lookup stops before custom-id next phase', () => { - const projectDir = createProject(); - try { - fs.writeFileSync( - path.join(projectDir, '.planning', 'ROADMAP.md'), - [ - '# Roadmap', - '', - '## v1.0.0', - '', - '### Phase 1: Numeric Phase', - '**Goal:** Users can sign in.', - '', - '### Phase custom-alpha: Custom Phase', - '**Goal:** Custom work.', - '**Mode:** mvp', - '', - ].join('\n'), - ); - const beforeFiles = snapshotProjectState(projectDir); - - const result = runGsdTools(['phase', 'mvp-mode', '1'], projectDir); - assert.equal(result.status, 0, result.stderr); - - const output = JSON.parse(result.stdout); - assert.equal(output.active, false); - assert.equal(output.source, 'none'); - assert.equal(output.roadmap_mode, null); - assert.deepEqual(snapshotProjectState(projectDir), beforeFiles); - } finally { - cleanup(projectDir); - } - }); - - test('phase.mvp-mode JSON error is typed and leaves project files untouched', () => { - const projectDir = createProject(); - try { - const beforeFiles = snapshotProjectState(projectDir); - const result = runGsdTools(['--json-errors', 'phase', 'mvp-mode'], projectDir); - assert.notEqual(result.status, 0); - assert.equal(result.stdout, ''); - - const error = JSON.parse(result.stderr); - assert.deepEqual(Object.keys(error).sort(), ['message', 'ok', 'reason']); - assert.equal(error.ok, false); - assert.equal(error.reason, 'usage'); - assert.equal(typeof error.message, 'string'); - assert.equal(/\n\s*at\s/.test(result.stderr), false, 'non-debug failure must not print a stack trace'); - assert.deepEqual(snapshotProjectState(projectDir), beforeFiles); - } finally { - cleanup(projectDir); - } - }); -}); diff --git a/tests/feat-3262-scan-phase-plans.test.cjs b/tests/feat-3262-scan-phase-plans.test.cjs deleted file mode 100644 index 3723448a5..000000000 --- a/tests/feat-3262-scan-phase-plans.test.cjs +++ /dev/null @@ -1,310 +0,0 @@ -/** - * Tests for the shared scanPhasePlans() helper (k014). - * - * Covers: - * - Top-level plans only (flat layout) - * - Top-level + nested layout (post-#3139) - * - Completed-summary detection (summaries >= plans) - * - Ignored files (OUTLINE, pre-bounce, CONTEXT, RESEARCH) - * - Empty phase dir → { planCount: 0, summaryCount: 0 } - * - Parity: helper produces correct counts for mixed flat+nested fixture tree - */ - -'use strict'; - -const { test, describe, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); -const os = require('os'); -const { cleanup } = require('./helpers.cjs'); - -// Helper under test — must exist at this path (GREEN phase wires it up) -const scanPhasePlans = require('../gsd-core/bin/lib/plan-scan.cjs'); - -// --------------------------------------------------------------------------- -// Fixture helpers -// --------------------------------------------------------------------------- - -let tmpDir; - -function phaseDir(name = 'phase') { - const d = path.join(tmpDir, name); - fs.mkdirSync(d, { recursive: true }); - return d; -} - -function touch(dir, ...filenames) { - for (const f of filenames) { - fs.writeFileSync(path.join(dir, f), ''); - } -} - -beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-plan-scan-')); -}); - -afterEach(() => { - cleanup(tmpDir); -}); - -// --------------------------------------------------------------------------- -// Basic shapes -// --------------------------------------------------------------------------- - -describe('scanPhasePlans — flat layout', () => { - test('empty directory → zero counts', () => { - const dir = phaseDir(); - const result = scanPhasePlans(dir); - assert.strictEqual(result.planCount, 0, 'planCount'); - assert.strictEqual(result.summaryCount, 0, 'summaryCount'); - }); - - test('bare PLAN.md counts as one plan', () => { - const dir = phaseDir(); - touch(dir, 'PLAN.md'); - const result = scanPhasePlans(dir); - assert.strictEqual(result.planCount, 1, 'planCount'); - assert.strictEqual(result.summaryCount, 0, 'summaryCount'); - }); - - test('canonical padded plan file (01-01-PLAN.md)', () => { - const dir = phaseDir(); - touch(dir, '01-01-PLAN.md'); - const result = scanPhasePlans(dir); - assert.strictEqual(result.planCount, 1, 'planCount'); - }); - - test('canonical padded plan + matching summary → completed', () => { - const dir = phaseDir(); - touch(dir, '01-01-PLAN.md', '01-01-SUMMARY.md'); - const result = scanPhasePlans(dir); - assert.strictEqual(result.planCount, 1); - assert.strictEqual(result.summaryCount, 1); - assert.strictEqual(result.completed, true, 'phase should be complete when summaries >= plans'); - }); - - test('plan without summary → not completed', () => { - const dir = phaseDir(); - touch(dir, '01-01-PLAN.md'); - const result = scanPhasePlans(dir); - assert.strictEqual(result.completed, false); - }); - - test('multiple plans all summarized → completed', () => { - const dir = phaseDir(); - touch(dir, '01-01-PLAN.md', '01-02-PLAN.md', '01-01-SUMMARY.md', '01-02-SUMMARY.md'); - const result = scanPhasePlans(dir); - assert.strictEqual(result.planCount, 2); - assert.strictEqual(result.summaryCount, 2); - assert.strictEqual(result.completed, true); - }); - - test('bare SUMMARY.md counts as one summary', () => { - const dir = phaseDir(); - touch(dir, 'PLAN.md', 'SUMMARY.md'); - const result = scanPhasePlans(dir); - assert.strictEqual(result.planCount, 1); - assert.strictEqual(result.summaryCount, 1); - }); - - test('extended-layout root file (5-PLAN-01-setup.md style)', () => { - // roadmap.cjs isPlanFile explicitly matches any .md with PLAN in name at root - // (not just ending with -PLAN.md). The canonical helper must too. - // e.g. gsd-plan-phase writes "5-PLAN-01-setup.md". - const dir = phaseDir(); - // The summary for this file follows the canonical *-SUMMARY.md suffix convention. - touch(dir, '3-PLAN-01-setup.md', '3-01-SUMMARY.md'); - const result = scanPhasePlans(dir); - assert.strictEqual(result.planCount, 1, 'extended-layout root plan counted'); - assert.strictEqual(result.summaryCount, 1, 'extended-layout root summary counted'); - }); -}); - -// --------------------------------------------------------------------------- -// Ignored files -// --------------------------------------------------------------------------- - -describe('scanPhasePlans — ignored files', () => { - test('PLAN-OUTLINE file is ignored (flat)', () => { - const dir = phaseDir(); - touch(dir, '01-01-PLAN.md', '01-01-PLAN-OUTLINE.md'); - const result = scanPhasePlans(dir); - assert.strictEqual(result.planCount, 1, 'OUTLINE should not count as a plan'); - }); - - test('pre-bounce file is ignored (flat)', () => { - const dir = phaseDir(); - touch(dir, '01-01-PLAN.md', '01-01-PLAN.pre-bounce.md'); - const result = scanPhasePlans(dir); - assert.strictEqual(result.planCount, 1, 'pre-bounce should not count as a plan'); - }); - - test('CONTEXT.md is not counted as a plan', () => { - const dir = phaseDir(); - touch(dir, 'PLAN.md', 'CONTEXT.md', '01-01-CONTEXT.md'); - const result = scanPhasePlans(dir); - assert.strictEqual(result.planCount, 1, 'CONTEXT files should not be plans'); - }); - - test('RESEARCH.md is not counted as a plan', () => { - const dir = phaseDir(); - touch(dir, 'PLAN.md', 'RESEARCH.md', '01-01-RESEARCH.md'); - const result = scanPhasePlans(dir); - assert.strictEqual(result.planCount, 1, 'RESEARCH files should not be plans'); - }); - - test('VERIFICATION.md is not counted as a plan', () => { - const dir = phaseDir(); - touch(dir, 'PLAN.md', 'VERIFICATION.md'); - const result = scanPhasePlans(dir); - assert.strictEqual(result.planCount, 1, 'VERIFICATION files should not be plans'); - }); -}); - -// --------------------------------------------------------------------------- -// Nested layout (post-#3139) -// --------------------------------------------------------------------------- - -describe('scanPhasePlans — nested layout', () => { - test('nested PLAN-NN-slug.md files counted', () => { - const dir = phaseDir(); - const plansDir = path.join(dir, 'plans'); - fs.mkdirSync(plansDir); - touch(plansDir, 'PLAN-01-setup.md', 'PLAN-02-impl.md'); - const result = scanPhasePlans(dir); - assert.strictEqual(result.planCount, 2, 'nested plans counted'); - assert.strictEqual(result.hasNestedPlans, true, 'hasNestedPlans flag set'); - }); - - test('nested SUMMARY-NN-slug.md files counted', () => { - const dir = phaseDir(); - const plansDir = path.join(dir, 'plans'); - fs.mkdirSync(plansDir); - touch(plansDir, 'PLAN-01-setup.md', 'SUMMARY-01-setup.md'); - const result = scanPhasePlans(dir); - assert.strictEqual(result.planCount, 1); - assert.strictEqual(result.summaryCount, 1); - assert.strictEqual(result.completed, true); - assert.deepStrictEqual(result.planFiles, ['plans/PLAN-01-setup.md']); - assert.deepStrictEqual(result.summaryFiles, ['plans/SUMMARY-01-setup.md']); - }); - - test('flat root + nested plans combined', () => { - const dir = phaseDir(); - const plansDir = path.join(dir, 'plans'); - fs.mkdirSync(plansDir); - // root: 1 plan, 1 summary - touch(dir, '01-01-PLAN.md', '01-01-SUMMARY.md'); - // nested: 2 plans, 1 summary - touch(plansDir, 'PLAN-01-setup.md', 'PLAN-02-impl.md', 'SUMMARY-01-setup.md'); - const result = scanPhasePlans(dir); - assert.strictEqual(result.planCount, 3, 'root + nested plans'); - assert.strictEqual(result.summaryCount, 2, 'root + nested summaries'); - assert.strictEqual(result.completed, false, 'not all plans have summaries'); - }); - - test('hasNestedPlans is false when plans/ directory absent', () => { - const dir = phaseDir(); - touch(dir, 'PLAN.md'); - const result = scanPhasePlans(dir); - assert.strictEqual(result.hasNestedPlans, false); - }); - - test('nested OUTLINE files are ignored', () => { - const dir = phaseDir(); - const plansDir = path.join(dir, 'plans'); - fs.mkdirSync(plansDir); - touch(plansDir, 'PLAN-01-setup.md', 'PLAN-01-OUTLINE.md'); - const result = scanPhasePlans(dir); - assert.strictEqual(result.planCount, 1, 'OUTLINE excluded in nested'); - }); - - test('nested pre-bounce files are ignored', () => { - const dir = phaseDir(); - const plansDir = path.join(dir, 'plans'); - fs.mkdirSync(plansDir); - touch(plansDir, 'PLAN-01-setup.md', 'PLAN-01.pre-bounce.md'); - const result = scanPhasePlans(dir); - assert.strictEqual(result.planCount, 1, 'pre-bounce excluded in nested'); - }); - - test('plans/ that is not readable as directory does not throw', () => { - const dir = phaseDir(); - // Create plans/ as a file (unreadable as directory) - fs.writeFileSync(path.join(dir, 'plans'), 'not-a-directory'); - touch(dir, 'PLAN.md'); - // Should not throw - const result = scanPhasePlans(dir); - assert.strictEqual(result.planCount, 1); - assert.strictEqual(result.hasNestedPlans, false); - }); -}); - -// --------------------------------------------------------------------------- -// Parity: helper output shape and mixed fixture -// --------------------------------------------------------------------------- - -describe('scanPhasePlans — call-site parity on mixed fixture', () => { - // Build a fixture tree that exercises both flat and nested layout: - // 01-foundation/ - // 01-01-PLAN.md - // 01-01-SUMMARY.md - // 01-01-PLAN-OUTLINE.md (should be ignored) - // 01-02-PLAN.md - // plans/ - // PLAN-01-setup.md - // SUMMARY-01-setup.md - - function buildMixedPhase() { - const dir = phaseDir('01-foundation'); - const plansDir = path.join(dir, 'plans'); - fs.mkdirSync(plansDir); - touch(dir, '01-01-PLAN.md', '01-01-SUMMARY.md', '01-01-PLAN-OUTLINE.md', '01-02-PLAN.md'); - touch(plansDir, 'PLAN-01-setup.md', 'SUMMARY-01-setup.md'); - return dir; - } - - test('scanPhasePlans() counts match expected values for mixed fixture', () => { - const dir = buildMixedPhase(); - const result = scanPhasePlans(dir); - // flat: 01-01-PLAN.md + 01-02-PLAN.md = 2 (OUTLINE ignored) - // nested: PLAN-01-setup.md = 1 - assert.strictEqual(result.planCount, 3, 'planCount should be 3'); - // flat: 01-01-SUMMARY.md = 1; nested: SUMMARY-01-setup.md = 1 - assert.strictEqual(result.summaryCount, 2, 'summaryCount should be 2'); - assert.strictEqual(result.completed, false, 'not all plans have summaries'); - assert.strictEqual(result.hasNestedPlans, true, 'nested layout present'); - }); - - test('scanPhasePlans() output shape has required fields', () => { - const dir = buildMixedPhase(); - const result = scanPhasePlans(dir); - assert.ok('planCount' in result, 'planCount field present'); - assert.ok('summaryCount' in result, 'summaryCount field present'); - assert.ok('completed' in result, 'completed field present'); - assert.ok('hasNestedPlans' in result, 'hasNestedPlans field present'); - assert.ok('planFiles' in result, 'planFiles field present'); - assert.ok('summaryFiles' in result, 'summaryFiles field present'); - assert.ok(Array.isArray(result.planFiles), 'planFiles is array'); - assert.ok(Array.isArray(result.summaryFiles), 'summaryFiles is array'); - }); - - test('parity baseline: 2 flat + 1 nested plans across all call sites', () => { - // This test documents the exact expected counts for a representative fixture. - // After the GREEN phase ports roadmap.cjs/state.cjs/init.cjs to use - // scanPhasePlans, those call sites delegate here and this assertion is - // the single contract all of them must satisfy. - const dir = phaseDir('02-api'); - touch(dir, '02-01-PLAN.md', '02-02-PLAN.md', '02-01-SUMMARY.md'); - const plansDir = path.join(dir, 'plans'); - fs.mkdirSync(plansDir); - touch(plansDir, 'PLAN-01-impl.md', 'SUMMARY-01-impl.md'); - - const result = scanPhasePlans(dir); - assert.strictEqual(result.planCount, 3, 'helper: 2 flat + 1 nested'); - assert.strictEqual(result.summaryCount, 2, 'helper: 1 flat + 1 nested'); - assert.strictEqual(result.completed, false, '2 summaries < 3 plans'); - assert.strictEqual(result.hasNestedPlans, true, 'plans/ dir exists with plans'); - }); -}); diff --git a/tests/feat-3309-human-verify-mode.test.cjs b/tests/feat-3309-human-verify-mode.test.cjs deleted file mode 100644 index 483d1db3a..000000000 --- a/tests/feat-3309-human-verify-mode.test.cjs +++ /dev/null @@ -1,183 +0,0 @@ -// allow-test-rule: source-text-is-the-product -// Planner and verifier agent .md files ARE the runtime contract loaded by -// the AI runtimes. Asserting that the canonical wording for the new -// `workflow.human_verify_mode` flag is present in those files is the only -// way to verify the agents will respect the flag at runtime. - -/** - * Enhancement #3309: workflow.human_verify_mode = end-of-phase - * - * "mid-flight" preserves the pre-#3309 behavior — the planner emits - * `` tasks, and the executor halts at - * each one. Each halt costs a full executor cold-start (CLAUDE.md, MEMORY.md, - * STATE.md, plan re-read) because subagent context is discarded across the - * pause. - * - * "end-of-phase" (the new default) instructs the planner NOT to emit - * `checkpoint:human-verify` tasks and instead embed the verification details - * into the relevant `auto` task's `` block. The verifier - * (Step 8) harvests these blocks at end-of-phase and consolidates them into the existing - * `human_needed` → HUMAN-UAT.md path, restoring the v1.35-shaped behavior - * the reporter wanted without resurrecting the v1.35 writer. - */ - -'use strict'; - -const { test, describe, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); -const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); - -function readConfig(tmpDir) { - const configPath = path.join(tmpDir, '.planning', 'config.json'); - return JSON.parse(fs.readFileSync(configPath, 'utf-8')); -} - -const REPO_ROOT = path.join(__dirname, '..'); - -// ─── Schema registration ────────────────────────────────────────────────────── - -describe('workflow.human_verify_mode in VALID_CONFIG_KEYS', () => { - test('is a recognized config key', () => { - const { VALID_CONFIG_KEYS } = require('../gsd-core/bin/lib/config.cjs'); - assert.ok( - VALID_CONFIG_KEYS.has('workflow.human_verify_mode'), - 'workflow.human_verify_mode should be in VALID_CONFIG_KEYS', - ); - }); -}); - -// ─── Default value (CJS) ────────────────────────────────────────────────────── - -describe('workflow.human_verify_mode default value', () => { - let tmpDir; - beforeEach(() => { tmpDir = createTempProject(); }); - afterEach(() => { cleanup(tmpDir); }); - - test('defaults to end-of-phase in new project config', () => { - const result = runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir }); - assert.ok(result.success, `config-ensure-section failed: ${result.error}`); - - const config = readConfig(tmpDir); - assert.strictEqual( - config.workflow.human_verify_mode, - 'end-of-phase', - 'workflow.human_verify_mode should default to "end-of-phase" — the cost-control mode is the project default; opt back into the pre-#3309 mid-flight behavior with config-set', - ); - }); -}); - -// ─── Round-trip ────────────────────────────────────────────────────────────── - -describe('workflow.human_verify_mode config round-trip', () => { - let tmpDir; - beforeEach(() => { - tmpDir = createTempProject(); - runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir }); - }); - afterEach(() => { cleanup(tmpDir); }); - - test('config-set end-of-phase persists to config.json', () => { - const setResult = runGsdTools('config-set workflow.human_verify_mode end-of-phase', tmpDir); - assert.ok(setResult.success, `config-set failed: ${setResult.error}`); - - const config = readConfig(tmpDir); - assert.strictEqual(config.workflow.human_verify_mode, 'end-of-phase'); - }); - - test('config-set mid-flight overwrites end-of-phase in config.json', () => { - runGsdTools('config-set workflow.human_verify_mode end-of-phase', tmpDir); - - const setResult = runGsdTools('config-set workflow.human_verify_mode mid-flight', tmpDir); - assert.ok(setResult.success, `config-set failed: ${setResult.error}`); - - const config = readConfig(tmpDir); - assert.strictEqual(config.workflow.human_verify_mode, 'mid-flight'); - }); - - test('persists in config.json as string', () => { - runGsdTools('config-set workflow.human_verify_mode end-of-phase', tmpDir); - - const config = readConfig(tmpDir); - assert.strictEqual(config.workflow.human_verify_mode, 'end-of-phase'); - assert.strictEqual(typeof config.workflow.human_verify_mode, 'string'); - }); - - test('rejects invalid mode values', () => { - const result = runGsdTools('config-set workflow.human_verify_mode midflight', tmpDir); - assert.strictEqual(result.success, false); - assert.match(result.error, /Invalid workflow\.human_verify_mode 'midflight'/); - assert.match(result.error, /mid-flight, end-of-phase/); - }); -}); - -// ─── Planner agent contract ────────────────────────────────────────────────── - -describe('agents/gsd-planner.md acknowledges workflow.human_verify_mode', () => { - let plannerSrc; - - test('loads', () => { - plannerSrc = fs.readFileSync(path.join(REPO_ROOT, 'agents', 'gsd-planner.md'), 'utf-8'); - assert.ok(plannerSrc.length > 0); - }); - - test('mentions workflow.human_verify_mode by canonical name', () => { - plannerSrc = plannerSrc || fs.readFileSync(path.join(REPO_ROOT, 'agents', 'gsd-planner.md'), 'utf-8'); - assert.ok( - plannerSrc.includes('workflow.human_verify_mode'), - 'planner must reference the flag by canonical key so the runtime can resolve config-driven behavior', - ); - }); - - test('explains the end-of-phase behavior (do NOT emit checkpoint:human-verify)', () => { - plannerSrc = plannerSrc || fs.readFileSync(path.join(REPO_ROOT, 'agents', 'gsd-planner.md'), 'utf-8'); - // The planner must instruct: when end-of-phase, do NOT emit checkpoint:human-verify - assert.ok( - /end-of-phase[\s\S]{0,400}checkpoint:human-verify/i.test(plannerSrc) || - /checkpoint:human-verify[\s\S]{0,400}end-of-phase/i.test(plannerSrc), - 'planner must couple "end-of-phase" mode with the rule that checkpoint:human-verify tasks are not emitted', - ); - }); - - test('routes deferred verification through the block on auto tasks', () => { - plannerSrc = plannerSrc || fs.readFileSync(path.join(REPO_ROOT, 'agents', 'gsd-planner.md'), 'utf-8'); - assert.ok( - /`?`?\s*[\s\S]{0,200}`?`?/i.test(plannerSrc) || - plannerSrc.includes('') || - plannerSrc.includes('``'), - 'planner must document the ... shape so the verifier can harvest deferred items', - ); - }); -}); - -// ─── Verifier agent contract ───────────────────────────────────────────────── - -describe('agents/gsd-verifier.md harvests deferred human verification items', () => { - test('Step 8 mentions harvesting blocks from PLAN.md', () => { - const verifierSrc = fs.readFileSync(path.join(REPO_ROOT, 'agents', 'gsd-verifier.md'), 'utf-8'); - assert.ok( - verifierSrc.includes('') || /[\s\S]{0,200}/i.test(verifierSrc), - 'verifier must instruct itself to harvest blocks from PLAN.md when human_verify_mode = end-of-phase', - ); - assert.ok( - verifierSrc.includes('human_verify_mode'), - 'verifier must reference the flag by canonical key', - ); - }); -}); - -// ─── References doc parity ─────────────────────────────────────────────────── - -describe('references/checkpoints.md documents the flag', () => { - test('mentions workflow.human_verify_mode in the human-verify section', () => { - const refSrc = fs.readFileSync( - path.join(REPO_ROOT, 'gsd-core', 'references', 'checkpoints.md'), - 'utf-8', - ); - assert.ok( - refSrc.includes('workflow.human_verify_mode'), - 'checkpoints reference must document the new flag so users know the cost-control alternative exists', - ); - }); -}); diff --git a/tests/feat-3594-parser-adversarial-frontmatter.test.cjs b/tests/feat-3594-parser-adversarial-frontmatter.test.cjs deleted file mode 100644 index 2e718977a..000000000 --- a/tests/feat-3594-parser-adversarial-frontmatter.test.cjs +++ /dev/null @@ -1,147 +0,0 @@ -/** - * Adversarial frontmatter-parser tests (#3594). - * - * Loads each file in `tests/fixtures/adversarial/frontmatter/` and pins - * the invariants `extractFrontmatter()` must satisfy. The fixtures - * encode hostile-but-realistic input shapes (duplicate keys, CRLF - * endings, unclosed blocks, Unicode, null bytes, huge but bounded - * payloads) that the parser will see in the wild because users edit - * planning files with multiple tools. - * - * Per CONTRIBUTING.md §"Testing Standards / Parser and project-file - * inputs", these are typed-IR assertions on parser return values — - * not prose-grep on rendered output. Property-style invariants for - * the roadmap parser live in - * `tests/feat-3594-parser-property-style.test.cjs`. - */ - -'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 { extractFrontmatter } = require('../gsd-core/bin/lib/frontmatter.cjs'); - -const FIXTURE_DIR = path.join(__dirname, 'fixtures', 'adversarial', 'frontmatter'); - -function loadFixture(name) { - // Read as buffer first so null bytes survive into the string. The - // CRLF fixture also requires we do NOT normalize line endings on read. - return fs.readFileSync(path.join(FIXTURE_DIR, name), 'utf-8'); -} - -describe('feat-3594: frontmatter parser handles duplicate keys deterministically', () => { - test('duplicate keys collapse to a single deterministic winner (last-wins is the current contract)', () => { - const content = loadFixture('duplicate-keys.md'); - const fm = extractFrontmatter(content); - - // The parser MUST return a single value per key — not an array of - // both, not a half-formed entry. Whichever value wins, the test pins - // the current behavior so a silent semantics change is a test failure. - assert.equal(typeof fm.title, 'string', 'title must be a string, not an array or object'); - assert.equal(typeof fm.status, 'string', 'status must be a string'); - // Current parser behavior: the second occurrence wins because each - // key: line overwrites the previous in the same indent context. - // Pin it so a change to first-wins becomes visible. - assert.equal(fm.title, 'Second', 'duplicate-key collapse must be last-wins (current contract)'); - assert.equal(fm.status, 'blocked', 'duplicate-key collapse must be last-wins (current contract)'); - // Untouched keys round-trip cleanly. - assert.equal(fm.phase, '01'); - }); -}); - -describe('feat-3594: frontmatter parser handles CRLF endings without bleed', () => { - test('CRLF-terminated frontmatter parses without trailing \\r in values', () => { - const content = loadFixture('crlf-mixed.md'); - const fm = extractFrontmatter(content); - // Each value MUST be \r-free. A bug in `\r?\n` handling would leak - // \r into the captured group. - assert.equal(fm.title, 'CRLF Title'); - assert.equal(fm.phase, '02'); - assert.ok(!/\r/.test(JSON.stringify(fm)), 'no \\r should appear in any parsed value'); - // Array items must also be \r-free. - assert.deepEqual(fm.plans, ['02-01', '02-02']); - }); -}); - -describe('feat-3594: frontmatter parser handles unclosed blocks safely', () => { - test('unclosed frontmatter block returns empty object, not partial parse', () => { - const content = loadFixture('unclosed-block.md'); - const fm = extractFrontmatter(content); - // The current contract: if the closing `---` is missing, the regex - // doesn't match and the parser returns {}. The test pins that — - // a partial parse (returning {title: 'Unclosed Block'}) would be a - // silent data-leak from the body into "frontmatter." - assert.deepEqual(fm, {}, 'unclosed block must yield empty frontmatter, not a partial parse'); - }); -}); - -describe('feat-3594: frontmatter parser preserves Unicode round-trip', () => { - test('non-ASCII keys and values survive parsing', () => { - const content = loadFixture('unicode-keys-and-values.md'); - const fm = extractFrontmatter(content); - assert.equal(fm.title, '日本語のタイトル'); - // The parser's key regex is /^(\s*)([a-zA-Z0-9_-]+):.../ so non-ASCII - // keys (like `相:`) won't be captured. Pin that current behavior so - // a future broadening to allow Unicode keys is visible (and so the - // ASCII-only contract is asserted, not silently relied on). - assert.equal(fm['相'], undefined, 'parser currently only recognizes ASCII keys (regression guard)'); - // The status field has an emoji — must survive. - assert.equal(fm.status, '🚧 in-flight'); - // Inline array with Greek letters. - assert.deepEqual(fm.tags, ['α', 'β', 'γ']); - }); -}); - -describe('feat-3594: frontmatter parser handles null bytes without truncation', () => { - test('null byte in a value is preserved or normalized, never silently truncates the rest', () => { - const content = loadFixture('null-byte-value.md'); - const fm = extractFrontmatter(content); - // The parser MUST NOT crash. It MUST NOT truncate the value at the - // null byte AND continue parsing as if the rest of the line never - // existed. We pin: (a) the title still parses, (b) the phase key - // following the null-byte line still parses (no early-termination), - // (c) the null-byte value itself is a string. - assert.equal(fm.title, 'Has null byte'); - assert.equal(fm.phase, '05', 'parser must continue past the null-byte line, not silently stop'); - assert.equal(typeof fm.weird, 'string'); - // The exact null-handling is documented by whatever the current - // parser does: either preserve the \x00 or strip it. Test pins one. - assert.ok(fm.weird.includes('before'), 'value before the null byte must be retained'); - }); -}); - -describe('feat-3594: frontmatter parser handles bounded-large inputs in reasonable time', () => { - test('64KB frontmatter with 2000 array items parses and returns the right shape', () => { - const content = loadFixture('huge-bounded.md'); - const fm = extractFrontmatter(content); - assert.equal(fm.phase, '06'); - assert.ok(Array.isArray(fm.plans), 'plans must be parsed as an array'); - assert.equal(fm.plans.length, 2000, 'all 2000 array items must be captured'); - assert.equal(fm.plans[0], 'item-00000'); - assert.equal(fm.plans[1999], 'item-01999'); - }); -}); - -// ─── Cross-cutting invariants over the whole fixture corpus ──────────────── - -describe('feat-3594: frontmatter parser does not throw on ANY corpus fixture', () => { - // Property-style: whatever weirdness lives in the corpus, extractFrontmatter - // must return an object — never throw, never return undefined/null. This is - // the floor every individual fixture also satisfies, but checking it as a - // sweep catches a future fixture addition where the author forgets to write - // a per-file test. - const fixtures = fs.readdirSync(FIXTURE_DIR).filter((f) => f.endsWith('.md') && f !== 'README.md'); - for (const fixture of fixtures) { - test(`fixture "${fixture}" — extractFrontmatter returns a plain object without throwing`, () => { - const content = loadFixture(fixture); - let fm; - assert.doesNotThrow(() => { fm = extractFrontmatter(content); }, `extractFrontmatter must not throw on ${fixture}`); - assert.equal(typeof fm, 'object', `${fixture}: result must be an object`); - assert.notEqual(fm, null, `${fixture}: result must not be null`); - assert.equal(Array.isArray(fm), false, `${fixture}: result must not be an array`); - }); - } -}); diff --git a/tests/feat-3594-parser-property-style.test.cjs b/tests/feat-3594-parser-property-style.test.cjs deleted file mode 100644 index 1dae24d90..000000000 --- a/tests/feat-3594-parser-property-style.test.cjs +++ /dev/null @@ -1,141 +0,0 @@ -/** - * Deterministic property-style parser tests (#3594). - * - * Follows TEST-EXAMPLES.md §"Deterministic Property-Style Parser Test": - * a bounded, seeded loop generates many malformed inputs and asserts a - * single invariant against each. On failure the seed and case index - * are printed so the failing input can be reproduced exactly. - * - * The generator is a small mulberry32 PRNG so this file has zero - * external dependencies and is fully reproducible across Node versions. - * Each test pins its own seed and case count; bumping either is a - * deliberate test change, not a flake source. - * - * Invariant tested (frontmatter): for any random text the parser must - * either return a plain object or throw — never return null/undefined, - * never hang, never propagate "Cannot read properties of …" V8 prose. - */ - -'use strict'; - -const { test } = require('node:test'); -const assert = require('node:assert/strict'); - -const { extractFrontmatter } = require('../gsd-core/bin/lib/frontmatter.cjs'); - -/** - * mulberry32 — small fast deterministic PRNG. Seed in, [0,1) out. - * Same input always produces the same sequence across Node versions. - */ -function mulberry32(seed) { - let a = seed >>> 0; - return function next() { - a = (a + 0x6D2B79F5) | 0; - let t = a; - t = Math.imul(t ^ (t >>> 15), t | 1); - t ^= t + Math.imul(t ^ (t >>> 7), t | 61); - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; -} - -/** - * Build a single malformed-ish frontmatter input. Components are mixed - * deterministically by the supplied PRNG. - */ -function makeInput(rng) { - const fragments = [ - '---\n', - 'title: Generated\n', - 'phase: 99\n', - 'plans:\n - a\n - b\n', - 'extra: \xff\xfe\xfd\n', // invalid UTF-8 bytes - 'unicode: 日本語\n', - 'crlf: ends\r\nin\rcr\n', - ' indented_key: value\n', - 'duplicate: first\nduplicate: second\n', - 'sparse:\n\n\n', - 'malformed_array: [a, "b", c\n', // unclosed inline array - 'null_byte: before\x00after\n', - ]; - // Pick a random subset of fragments in random order. Always include - // the opening `---`. Closing `---` is included by 50% of cases so we - // exercise both well-formed and unclosed shapes. - const head = fragments[0]; - const rest = shuffle(fragments.slice(1), rng).slice(0, 1 + Math.floor(rng() * 6)); - const closing = rng() < 0.5 ? '---\n' : ''; - return head + rest.join('') + closing + '\nBody.\n'; -} - -/** - * Fisher-Yates shuffle driven by the supplied PRNG. Returns a new - * array; does not mutate the input. Replaces the previous - * `arr.sort(() => rng() - 0.5)` which was non-transitive — the - * resulting order depended on V8's sort implementation, not only on - * the seed, so failing cases were unreproducible across Node versions. - * Fisher-Yates is O(n), transitive (no comparator), and depends only - * on the RNG output. Codex review on PR #3633 / #3594. - */ -function shuffle(arr, rng) { - const out = arr.slice(); - for (let i = out.length - 1; i > 0; i--) { - const j = Math.floor(rng() * (i + 1)); - const tmp = out[i]; - out[i] = out[j]; - out[j] = tmp; - } - return out; -} - -test('extractFrontmatter is total over 500 deterministic random inputs (seed=1234)', () => { - const seed = 1234; - const rng = mulberry32(seed); - const count = 500; - for (let i = 0; i < count; i++) { - const input = makeInput(rng); - let result; - try { - result = extractFrontmatter(input); - } catch (err) { - // If the parser throws, the failure must be a controlled one — - // not a V8 "Cannot read properties of undefined" that signals a - // null-deref bug. Print the seed and case index so the input - // can be reproduced exactly. - const msg = String((err && err.message) || err); - assert.doesNotMatch( - msg, - /Cannot read propert/i, - `seed=${seed} case=${i}: parser must not propagate null-deref TypeError; input=${JSON.stringify(input)}`, - ); - continue; - } - // No throw: result MUST be a plain object (not null, not array, not - // primitive). Print enough on failure to reproduce. - assert.equal(typeof result, 'object', `seed=${seed} case=${i}: result must be object, got ${typeof result}`); - assert.notEqual(result, null, `seed=${seed} case=${i}: result must not be null`); - assert.equal(Array.isArray(result), false, `seed=${seed} case=${i}: result must not be an array`); - } -}); - -test('extractFrontmatter handles large frontmatter blocks without body bleed', () => { - // Deterministic large-input coverage replaces the former wall-clock ratio - // guard. Timing assertions are host-sensitive; this pins the parser contract - // instead: parse every frontmatter line once and stop at the first closing - // delimiter before the body. - - /** Build a frontmatter string with exactly `lineCount` key:value lines. */ - function buildScaleInput(lineCount) { - let s = '---\n'; - for (let i = 0; i < lineCount; i++) { - s += `key${i}: value${i}\n`; - } - return s + '---\nBody.\n'; - } - - for (const lineCount of [20, 200, 2000]) { - const result = extractFrontmatter(buildScaleInput(lineCount) + 'body_key: not-frontmatter\n'); - assert.equal(Object.keys(result).length, lineCount); - assert.equal(result.key0, 'value0'); - assert.equal(result[`key${lineCount - 1}`], `value${lineCount - 1}`); - assert.equal(result.body_key, undefined); - } -}); diff --git a/tests/feat-3595-fs-fault-injection-atomic-write.test.cjs b/tests/feat-3595-fs-fault-injection-atomic-write.test.cjs deleted file mode 100644 index 2111099c4..000000000 --- a/tests/feat-3595-fs-fault-injection-atomic-write.test.cjs +++ /dev/null @@ -1,480 +0,0 @@ -/** - * Filesystem fault-injection coverage for the canonical atomic-write - * seam (#3595). - * - * `platformWriteSync` in `gsd-core/bin/lib/shell-command-projection.cjs` - * is the shared seam every config/state/generated-artifact writer in - * the CJS layer routes through. Its contract: - * - * 1. mkdirSync(dirname, { recursive: true }) — ensure parent exists. - * 2. writeFileSync(, content) — write to a sibling tmpfile - * named `.tmp.`. - * 3. renameSync(, filePath) — atomic publish. - * 4. On any error in steps 2-3: unlinkSync() (best-effort) - * then writeFileSync(filePath, content) directly as a fallback. - * - * Per CONTRIBUTING.md §"QA Matrix Requirements / Filesystem writes and - * installers" the tests below use `mock.method()` against the real `fs` - * seam to drive each fault mode, restore mocks with `t.after()`, and - * assert on observable post-conditions (file existence, content, - * presence/absence of orphan tmp files, propagated error code) — not - * on prose. - * - * Pre-existing behavior gaps surfaced and PINNED (not fixed in this - * PR — #3595 is test coverage, fixes belong in separate issues): - * - * - The "fall back to direct write" branch silently swallows the - * ORIGINAL error from the tmp+rename path. If the fallback ALSO - * fails, the operator only sees the fallback's error — not the - * original cause. Tests document this. - * - On EACCES against the parent directory mkdir, the error - * escapes (no try/catch around mkdirSync). Pinned. - */ - -'use strict'; - -const { test, mock } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const { - platformWriteSync, - platformEnsureDir, -} = require('../gsd-core/bin/lib/shell-command-projection.cjs'); - -/** - * Create a fresh real-fs scratch dir per test so no two faults share - * state. Returns the directory; caller must clean up. - */ -const { createTempDir, cleanup } = require('./helpers.cjs'); -const mkScratch = (name) => createTempDir(`fs-fault-${name}-`); - -/** - * Enumerate orphan tmp files left behind by platformWriteSync. The - * tmp shape is `.tmp.`; we match that pattern strictly - * so a test that happens to write a real `*.tmp.123` doesn't get a - * false positive. - */ -function orphanTmpFiles(dir) { - if (!fs.existsSync(dir)) return []; - return fs.readdirSync(dir).filter((n) => /\.tmp\.\d+$/.test(n)); -} - -// ─── Happy path baseline ──────────────────────────────────────────────────── - -test('platformWriteSync happy path writes content atomically (baseline for fault tests)', (t) => { - const dir = mkScratch('happy'); - t.after(() => cleanup(dir)); - const file = path.join(dir, 'config.json'); - platformWriteSync(file, '{"k":"v"}\n'); - assert.equal(fs.readFileSync(file, 'utf-8'), '{"k":"v"}\n'); - assert.deepEqual(orphanTmpFiles(dir), [], 'happy path must leave no orphan tmp file'); -}); - -// ─── Rename failure → falls back to direct write ──────────────────────────── - -test('platformWriteSync recovers when renameSync fails (EXDEV cross-device fallback)', (t) => { - const dir = mkScratch('exdev'); - t.after(() => cleanup(dir)); - const file = path.join(dir, 'config.json'); - - // Simulate rename failing once (e.g. cross-device move on a CI runner - // with overlayfs). The fallback path must write the content directly. - let renameCalls = 0; - const renameMock = mock.method(fs, 'renameSync', (_src, _dest) => { - renameCalls++; - const err = new Error('EXDEV: cross-device link not permitted'); - err.code = 'EXDEV'; - throw err; - }); - t.after(() => renameMock.mock.restore()); - - platformWriteSync(file, 'fallback content\n'); - - // File exists and has the new content — fallback wrote it directly. - assert.equal(fs.readFileSync(file, 'utf-8'), 'fallback content\n'); - assert.equal(renameCalls, 1, 'renameSync was called exactly once before falling back'); - // The tmp file was unlinked by the fallback path's best-effort cleanup. - assert.deepEqual(orphanTmpFiles(dir), [], 'tmp file must be cleaned up after rename failure'); -}); - -// ─── #1540: transient Windows lock (EPERM/EBUSY/EACCES) is RETRIED, never -// fallen back to a non-atomic truncating write ─────────────────── - -test('platformWriteSync retries a transient EPERM rename and publishes atomically (#1540)', (t) => { - const dir = mkScratch('eperm-transient'); - t.after(() => cleanup(dir)); - const file = path.join(dir, 'STATE.md'); - - // A reader briefly holds the target open → rename throws EPERM once, then clears. - let renameCalls = 0; - const originalRename = fs.renameSync; - const renameMock = mock.method(fs, 'renameSync', (src, dest) => { - renameCalls++; - if (renameCalls === 1) { - const err = new Error('EPERM: a reader holds the target open'); - err.code = 'EPERM'; - throw err; - } - return originalRename.call(fs, src, dest); - }); - t.after(() => renameMock.mock.restore()); - - platformWriteSync(file, 'published\n'); - - assert.equal(renameCalls, 2, 'rename retried after a transient EPERM (not a single-shot non-atomic fallback)'); - assert.equal(fs.statSync(file).isFile(), true); - assert.ok(fs.statSync(file).size > 0, 'target published, not truncated'); - assert.deepEqual(orphanTmpFiles(dir), [], 'atomic publish leaves no tmp orphan'); -}); - -test('platformWriteSync surfaces a PERSISTENT EPERM instead of truncating a concurrent reader (#1540)', (t) => { - const dir = mkScratch('eperm-persistent'); - t.after(() => cleanup(dir)); - const file = path.join(dir, 'STATE.md'); - // A reader is mid-read on `file` with known content. The old blanket fallback - // would non-atomically writeFileSync over it — truncating the reader. The fix - // must surface the error and leave the existing file byte-for-byte intact. - fs.writeFileSync(file, 'OLD CONTENT A READER IS MID-READ ON\n'); - const sizeBefore = fs.statSync(file).size; - - let renameCalls = 0; - const renameMock = mock.method(fs, 'renameSync', () => { - renameCalls++; - const err = new Error('EPERM: reader holds the target open'); - err.code = 'EPERM'; - throw err; - }); - t.after(() => renameMock.mock.restore()); - - let caught; - try { - platformWriteSync(file, 'NEW CONTENT\n'); - } catch (err) { - caught = err; - } - - assert.ok(caught, 'a persistent rename lock must surface as an error, not a silent truncating write'); - assert.equal(caught.code, 'EPERM'); - assert.equal(renameCalls, 3, 'rename retried up to the bounded limit before surfacing'); - // Negative proof: the concurrent reader's file was NOT truncated/overwritten. - assert.equal(fs.statSync(file).size, sizeBefore, 'target left intact — no non-atomic write happened'); - assert.equal(fs.readFileSync(file, 'utf-8'), 'OLD CONTENT A READER IS MID-READ ON\n'); - assert.deepEqual(orphanTmpFiles(dir), [], 'tmp cleaned up after surfacing the error'); -}); - -// ─── Tmp write failure → falls back to direct write ───────────────────────── - -test('platformWriteSync falls back when initial tmp writeFileSync fails (ENOSPC)', (t) => { - const dir = mkScratch('enospc'); - t.after(() => cleanup(dir)); - const file = path.join(dir, 'config.json'); - - // Make the FIRST writeFileSync (to .tmp.) fail with ENOSPC. The - // SECOND writeFileSync (the fallback, direct to filePath) succeeds. - let writeCalls = 0; - const realWrite = fs.writeFileSync; - const writeMock = mock.method(fs, 'writeFileSync', function (target, data, opts) { - writeCalls++; - if (writeCalls === 1) { - // First call is to the tmp path. - assert.match(String(target), /\.tmp\.\d+$/, 'first write must be to the tmp path'); - const err = new Error('ENOSPC: no space left on device'); - err.code = 'ENOSPC'; - throw err; - } - // Second call is the fallback to the real target. - assert.equal(target, file, 'fallback write must target the original file path'); - return realWrite.call(fs, target, data, opts); - }); - t.after(() => writeMock.mock.restore()); - - platformWriteSync(file, 'recovered\n'); - - assert.equal(writeCalls, 2, 'expected exactly 2 writeFileSync calls (tmp fail + fallback)'); - assert.equal(fs.readFileSync(file, 'utf-8'), 'recovered\n'); - // The fallback path tries unlinkSync on the tmp; the tmp never - // existed (its write failed), so unlink throws ENOENT and is - // swallowed by the inner catch. Either way: no orphan. - assert.deepEqual(orphanTmpFiles(dir), []); -}); - -// ─── Both attempts fail → error propagates (pinned current behavior) ──────── - -test('platformWriteSync propagates the FALLBACK error when both tmp and fallback writes fail', (t) => { - const dir = mkScratch('double-fail'); - t.after(() => cleanup(dir)); - const file = path.join(dir, 'config.json'); - - let writeCalls = 0; - const writeMock = mock.method(fs, 'writeFileSync', function () { - writeCalls++; - const err = new Error( - writeCalls === 1 - ? 'ENOSPC: original failure on tmp write' - : 'EACCES: permission denied on fallback write', - ); - err.code = writeCalls === 1 ? 'ENOSPC' : 'EACCES'; - throw err; - }); - t.after(() => writeMock.mock.restore()); - - // The current implementation does NOT chain the original cause; the - // fallback's error is what surfaces. This test pins that behavior so a - // future "preserve original error in .cause" fix is a visible change - // (open follow-up). - let caught; - try { - platformWriteSync(file, 'wont-write\n'); - } catch (err) { - caught = err; - } - assert.ok(caught, 'double-failure must throw'); - assert.equal(caught.code, 'EACCES', 'currently the fallback error wins (open: should chain via .cause)'); - // The original file was never created. - assert.equal(fs.existsSync(file), false); - // No orphan tmp left because the tmp write failed before any file was created. - assert.deepEqual(orphanTmpFiles(dir), []); -}); - -// ─── mkdirSync failure → escapes immediately (no try/catch upstream) ──────── - -test('platformWriteSync propagates mkdirSync failure unchanged (no swallowed parent-dir errors)', (t) => { - const dir = mkScratch('mkdir-fail'); - t.after(() => cleanup(dir)); - const file = path.join(dir, 'deep', 'nested', 'config.json'); - - const mkdirMock = mock.method(fs, 'mkdirSync', () => { - const err = new Error('EACCES: permission denied creating directory'); - err.code = 'EACCES'; - throw err; - }); - t.after(() => mkdirMock.mock.restore()); - - let caught; - try { - platformWriteSync(file, 'never-reached\n'); - } catch (err) { - caught = err; - } - assert.ok(caught, 'mkdir failure must propagate'); - assert.equal(caught.code, 'EACCES', 'mkdir error code must be preserved'); - // No partial write happened. - assert.equal(fs.existsSync(file), false); -}); - -// ─── Target path is a directory (writing OVER a dir is undefined) ────────── - -test('platformWriteSync against a target path that is an existing directory fails cleanly', (t) => { - const dir = mkScratch('target-is-dir'); - t.after(() => cleanup(dir)); - const file = path.join(dir, 'collides'); - // Pre-create the target AS a directory so the rename step would - // collide with a directory at the destination. - fs.mkdirSync(file); - - let caught; - try { - platformWriteSync(file, 'shouldnt-overwrite-dir\n'); - } catch (err) { - caught = err; - } - assert.ok(caught, 'writing over an existing directory must fail'); - // We don't pin the exact code (Node varies: EISDIR on rename, EPERM on - // some platforms). We pin: a real error code (string starting with E) - // surfaces, AND the directory was not replaced by a file. - assert.equal(typeof caught.code, 'string'); - assert.match(caught.code, /^E[A-Z]+$/, `expected an errno-style code, got ${caught.code}`); - assert.equal(fs.statSync(file).isDirectory(), true, 'pre-existing directory must remain a directory'); -}); - -// ─── Path with spaces, unicode, newline characters ───────────────────────── - -test('platformWriteSync handles paths with spaces, unicode, and newline characters', (t) => { - const dir = mkScratch('weird-path'); - t.after(() => cleanup(dir)); - const cases = [ - 'has spaces in name.json', - 'unicode-日本語-name.json', - ]; - if (process.platform !== 'win32') { - // Tab (0x09) and newline (0x0A) in filenames are POSIX-valid but - // Windows-illegal (NTFS forbids control characters 0x00–0x1F). Append - // both only on POSIX so cross-platform CI stays green. - cases.push('with\ttab.json'); - cases.push('with\nnewline.json'); - } - - for (const name of cases) { - const file = path.join(dir, name); - platformWriteSync(file, `payload for ${name}\n`); - assert.equal( - fs.readFileSync(file, 'utf-8'), - `payload for ${name}\n`, - `roundtrip failed for path "${JSON.stringify(name)}"`, - ); - } - assert.deepEqual(orphanTmpFiles(dir), [], 'no tmp orphans across the corpus'); -}); - -// ─── Orphan-tmp cleanup invariant ────────────────────────────────────────── - -test('platformWriteSync never leaks a tmp file after a successful happy-path write', (t) => { - const dir = mkScratch('no-orphan-happy'); - t.after(() => cleanup(dir)); - for (let i = 0; i < 25; i++) { - platformWriteSync(path.join(dir, `f-${i}.json`), `{"i":${i}}\n`); - } - // 25 real files, 0 tmp orphans. - const entries = fs.readdirSync(dir); - const tmpCount = entries.filter((n) => /\.tmp\.\d+$/.test(n)).length; - const realCount = entries.length - tmpCount; - assert.equal(realCount, 25); - assert.equal(tmpCount, 0); -}); - -// ─── platformEnsureDir is idempotent and chains errors ───────────────────── - -test('platformEnsureDir is idempotent on an existing directory', (t) => { - const dir = mkScratch('ensure-idem'); - t.after(() => cleanup(dir)); - const target = path.join(dir, 'a', 'b', 'c'); - // First call creates; subsequent calls must not throw EEXIST. - platformEnsureDir(target); - assert.equal(fs.statSync(target).isDirectory(), true); - // Repeat. - platformEnsureDir(target); - platformEnsureDir(target); - assert.equal(fs.statSync(target).isDirectory(), true); -}); - -test('platformEnsureDir propagates EACCES when parent dir is unwritable', (t) => { - const dir = mkScratch('ensure-fail'); - t.after(() => cleanup(dir)); - - const mkdirMock = mock.method(fs, 'mkdirSync', () => { - const err = new Error('EACCES: permission denied'); - err.code = 'EACCES'; - throw err; - }); - t.after(() => mkdirMock.mock.restore()); - - let caught; - try { - platformEnsureDir(path.join(dir, 'cannot-create')); - } catch (err) { - caught = err; - } - assert.ok(caught); - assert.equal(caught.code, 'EACCES'); -}); - -// ─── Symlink-following / escape behavior ──────────────────────────────────── - -test('platformWriteSync REPLACES a symlink with a regular file rather than following it (safe behavior)', (t) => { - // Security-relevant invariant: if the destination path is a symlink - // pointing somewhere the user did not intend the writer to touch - // (e.g. an attacker-planted symlink in `.planning/` pointing at - // `~/.ssh/authorized_keys`), the writer must NOT follow it and - // clobber the target. The rename-based atomic-write pattern delivers - // this property: `renameSync(tmp, symlinkPath)` replaces the symlink - // entry in the parent directory with the regular file at `tmp`. - // After the call: - // - `linkPath` is a regular file with the new content. - // - The original `realTarget` is UNTOUCHED. - // This test pins that property so a future refactor (e.g. switching - // to `fs.writeFileSync(linkPath, ...)` which follows symlinks) is a - // visible regression. - if (process.platform === 'win32') { - t.skip('symlinks on Win32 need admin'); - return; - } - - const dir = mkScratch('symlink-replace'); - t.after(() => cleanup(dir)); - - const realTarget = path.join(dir, 'real-target.json'); - fs.writeFileSync(realTarget, 'original — must not be touched\n'); - const linkPath = path.join(dir, 'link.json'); - fs.symlinkSync(realTarget, linkPath); - - platformWriteSync(linkPath, 'new content\n'); - - // The real target is UNTOUCHED — the safety property. - assert.equal( - fs.readFileSync(realTarget, 'utf-8'), - 'original — must not be touched\n', - 'symlink target must be preserved when writer writes "through" the link', - ); - // The link entry is now a regular file with the new content. - const stat = fs.lstatSync(linkPath); - assert.equal(stat.isSymbolicLink(), false, 'symlink entry replaced by a regular file (atomic rename semantics)'); - assert.equal(stat.isFile(), true); - assert.equal(fs.readFileSync(linkPath, 'utf-8'), 'new content\n'); -}); - -test('platformWriteSync against a broken symlink replaces it with the intended file', (t) => { - if (process.platform === 'win32') { - t.skip('symlinks on Win32 need admin'); - return; - } - const dir = mkScratch('symlink-broken'); - t.after(() => cleanup(dir)); - const link = path.join(dir, 'dangling.json'); - fs.symlinkSync(path.join(dir, 'does-not-exist'), link); - assert.equal(fs.lstatSync(link).isSymbolicLink(), true, 'pre-check: link is dangling'); - - platformWriteSync(link, 'real content\n'); - - assert.equal(fs.readFileSync(link, 'utf-8'), 'real content\n'); - assert.equal(fs.lstatSync(link).isSymbolicLink(), false, 'broken link replaced with regular file'); -}); - -// ─── Concurrent-write collision ──────────────────────────────────────────── - -test('platformWriteSync survives a concurrent collision on the same target path', (t) => { - // Two consecutive writes to the same path with DIFFERENT contents, - // separated by a setImmediate boundary so the second can interleave - // a mock-injected error mid-flight. The contract pinned here: after - // both writes complete, the file is parseable and contains ONE of the - // two contents — never a half-written corrupt blob. - // - // (True parallel writes from separate processes are out of scope — - // the writer is sync. This exercises mid-flight error recovery, which - // is the same concurrency hazard at a lower granularity.) - const dir = mkScratch('concurrent'); - t.after(() => cleanup(dir)); - const file = path.join(dir, 'race.json'); - - // First write completes normally. - platformWriteSync(file, '{"writer":"first"}\n'); - // Second write: inject a transient EBUSY on the first rename attempt, - // then succeed on the bounded retry (#1540). Capture the real renameSync - // BEFORE installing the mock so the retry attempt delegates to the real - // implementation. The previous form referenced a non-existent - // `fs.renameSync.wrapped` property — that branch would silently no-op - // instead of delegating. - let renameCalls = 0; - const originalRename = fs.renameSync; - const renameMock = mock.method(fs, 'renameSync', (src, dest) => { - renameCalls++; - if (renameCalls === 1) { - const err = new Error('EBUSY: file is locked'); - err.code = 'EBUSY'; - throw err; - } - return originalRename.call(fs, src, dest); - }); - t.after(() => renameMock.mock.restore()); - - platformWriteSync(file, '{"writer":"second"}\n'); - - // The bounded retry re-published the 'second' content atomically. - const final = fs.readFileSync(file, 'utf-8'); - // Must be valid JSON — never a half-merged corruption. - assert.doesNotThrow(() => JSON.parse(final), 'file must remain parseable after the contested write'); - // Must be the SECOND writer's content (it called platformWriteSync - // after the first, and the fallback completed). - assert.equal(final, '{"writer":"second"}\n'); - assert.deepEqual(orphanTmpFiles(dir), [], 'no tmp orphans after the contested write'); -}); diff --git a/tests/feat-488-effort-sync.test.cjs b/tests/feat-488-effort-sync.test.cjs deleted file mode 100644 index c76179c00..000000000 --- a/tests/feat-488-effort-sync.test.cjs +++ /dev/null @@ -1,276 +0,0 @@ -// Tests for gsd-tools effort sync command (#488) -// Verifies that effort frontmatter in installed agent files can be re-synced -// when effort config changes after initial install. -// allow-test-rule: structural-regression-guard — readFileSync asserts on installed agent .md files (the product under mutation) to verify dry-run safety and apply correctness; stderr.includes guards the CLI argument-rejection contract. - -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const os = require('node:os'); -const { spawnSync } = require('node:child_process'); - -const { cleanup } = require('./helpers.cjs'); - -const GSD_TOOLS = path.resolve(__dirname, '../gsd-core/bin/gsd-tools.cjs'); - -function runCli(args, env = {}) { - const result = spawnSync(process.execPath, [GSD_TOOLS, ...args], { - encoding: 'utf8', - env: { ...process.env, GSD_TEST_MODE: '1', ...env }, - }); - return result; -} - -function makeTmpDir(prefix) { - return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); -} - -// output() in core.cjs uses fs.writeSync(1, data) — intercept fd=1 writes. -// Pass raw=false so output() emits JSON (raw=true emits the plain rawValue string). -function captureOutput(fn) { - const origWriteSync = fs.writeSync; - let captured = ''; - fs.writeSync = (fd, data) => { - if (fd === 1) captured += data; - else origWriteSync(fd, data); - }; - try { - fn(); - } finally { - fs.writeSync = origWriteSync; - } - return JSON.parse(captured); -} - -function makeAgentsDir(tmpDir) { - const agentsDir = path.join(tmpDir, 'agents'); - fs.mkdirSync(agentsDir, { recursive: true }); - return agentsDir; -} - -function writePlanningConfig(tmpDir, effortConfig) { - const planningDir = path.join(tmpDir, '.planning'); - fs.mkdirSync(planningDir, { recursive: true }); - fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify({ effort: effortConfig })); -} - -const AGENT_WITH_EFFORT = `--- -name: gsd-planner -description: Plans phases for GSD milestones -effort: medium ---- -Body of the agent. -`; - -const AGENT_WITHOUT_EFFORT = `--- -name: gsd-executor -description: Executes GSD phase plans ---- -Body of the agent. -`; - -describe('feat-488: effort sync command', () => { - test('dry-run mode reports pending changes without writing files', () => { - const tmpDir = makeTmpDir('effort-sync-dry-'); - const agentsDir = makeAgentsDir(tmpDir); - const agentPath = path.join(agentsDir, 'gsd-planner.md'); - fs.writeFileSync(agentPath, AGENT_WITH_EFFORT); - writePlanningConfig(tmpDir, { default: 'high', agent_overrides: { 'gsd-planner': 'xhigh' } }); - - const { cmdEffortSync } = require('../gsd-core/bin/lib/commands.cjs'); - const result = captureOutput(() => - cmdEffortSync(tmpDir, false, { dryRun: true, configDir: tmpDir, runtime: 'claude' }) - ); - - assert.equal(result.dry_run, true); - assert.equal(result.synced, 1, 'should report 1 pending change'); - assert.equal(result.changes[0].agent, 'gsd-planner'); - assert.equal(result.changes[0].from, 'medium'); - assert.equal(result.changes[0].to, 'xhigh'); - - // dry-run must not modify the file - assert.ok(fs.readFileSync(agentPath, 'utf8').includes('effort: medium'), 'dry-run must not write file'); - - cleanup(tmpDir); - }); - - test('--apply mode rewrites effort: frontmatter to new config value', () => { - const tmpDir = makeTmpDir('effort-sync-apply-'); - const agentsDir = makeAgentsDir(tmpDir); - const agentPath = path.join(agentsDir, 'gsd-planner.md'); - fs.writeFileSync(agentPath, AGENT_WITH_EFFORT); - writePlanningConfig(tmpDir, { default: 'low', agent_overrides: { 'gsd-planner': 'xhigh' } }); - - const { cmdEffortSync } = require('../gsd-core/bin/lib/commands.cjs'); - const result = captureOutput(() => - cmdEffortSync(tmpDir, false, { dryRun: false, configDir: tmpDir, runtime: 'claude' }) - ); - - assert.equal(result.dry_run, false); - assert.equal(result.synced, 1); - - const updated = fs.readFileSync(agentPath, 'utf8'); - assert.ok(updated.includes('effort: xhigh'), 'file must be updated to xhigh'); - assert.ok(!updated.includes('effort: medium'), 'old effort value must be gone'); - - cleanup(tmpDir); - }); - - test('skips agents where effort: already matches config', () => { - const tmpDir = makeTmpDir('effort-sync-noop-'); - const agentsDir = makeAgentsDir(tmpDir); - const agentPath = path.join(agentsDir, 'gsd-planner.md'); - // Already has the correct value - fs.writeFileSync(agentPath, AGENT_WITH_EFFORT.replace('effort: medium', 'effort: xhigh')); - writePlanningConfig(tmpDir, { agent_overrides: { 'gsd-planner': 'xhigh' } }); - - const { cmdEffortSync } = require('../gsd-core/bin/lib/commands.cjs'); - const result = captureOutput(() => - cmdEffortSync(tmpDir, false, { dryRun: false, configDir: tmpDir, runtime: 'claude' }) - ); - - assert.equal(result.synced, 0, 'nothing to sync when already matching'); - assert.equal(result.skipped, 1); - - cleanup(tmpDir); - }); - - test('injects effort: into agent files that lack the frontmatter key', () => { - const tmpDir = makeTmpDir('effort-sync-inject-'); - const agentsDir = makeAgentsDir(tmpDir); - const agentPath = path.join(agentsDir, 'gsd-executor.md'); - fs.writeFileSync(agentPath, AGENT_WITHOUT_EFFORT); - writePlanningConfig(tmpDir, { default: 'max' }); - - const { cmdEffortSync } = require('../gsd-core/bin/lib/commands.cjs'); - const result = captureOutput(() => - cmdEffortSync(tmpDir, false, { dryRun: false, configDir: tmpDir, runtime: 'claude' }) - ); - - assert.equal(result.synced, 1, 'should inject effort into agent missing the key'); - assert.equal(result.changes[0].from, null); - assert.equal(result.changes[0].to, 'max'); - assert.ok(fs.readFileSync(agentPath, 'utf8').includes('effort: max'), 'effort must be injected'); - - cleanup(tmpDir); - }); - - test('non-claude runtime exits cleanly with informative reason field', () => { - const tmpDir = makeTmpDir('effort-sync-gemini-'); - - const { cmdEffortSync } = require('../gsd-core/bin/lib/commands.cjs'); - const result = captureOutput(() => - cmdEffortSync(tmpDir, false, { dryRun: true, runtime: 'gemini' }) - ); - - assert.ok(result.reason, 'should include a reason message for unsupported runtime'); - assert.equal(result.synced, 0); - - cleanup(tmpDir); - }); - - test('home-default effort config gap: applies home-level effort when project config has no effort section', () => { - // The key #488 scenario: user changed ~/.gsd/defaults.json effort settings - // after install, but the project .planning/config.json has no effort section. - // cmdEffortSync must pick up the home config (via readGsdEffectiveEffortConfig), - // not fall back to 'high' (which loadConfig would return). - // - // readGsdEffectiveEffortConfig calls os.homedir() directly, and os.homedir() - // is live (respects process.env.HOME). We redirect HOME to an isolated - // tmpHome so the test is hermetic and can assert the real outcome. - const tmpHome = makeTmpDir('effort-sync-homecfg-'); - const tmpDir = makeTmpDir('effort-sync-project-'); - const agentsDir = makeAgentsDir(tmpDir); - const agentPath = path.join(agentsDir, 'gsd-planner.md'); - fs.writeFileSync(agentPath, AGENT_WITH_EFFORT); // current: effort: medium - - // Project has .planning/config.json with NO effort section - const planningDir = path.join(tmpDir, '.planning'); - fs.mkdirSync(planningDir, { recursive: true }); - fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify({ model_profile: 'balanced' })); - - // Home defaults set effort.default = low - const gsdDir = path.join(tmpHome, '.gsd'); - fs.mkdirSync(gsdDir, { recursive: true }); - fs.writeFileSync(path.join(gsdDir, 'defaults.json'), JSON.stringify({ effort: { default: 'low' } })); - - // Isolate HOME (and USERPROFILE for Windows parity) so - // readGsdEffectiveEffortConfig reads our fixture, not the - // developer's real ~/.gsd/defaults.json. - const origHome = process.env.HOME; - const origUserProfile = process.env.USERPROFILE; - process.env.HOME = tmpHome; - process.env.USERPROFILE = tmpHome; - - const { cmdEffortSync } = require('../gsd-core/bin/lib/commands.cjs'); - let result; - try { - result = captureOutput(() => - cmdEffortSync(tmpDir, false, { dryRun: false, configDir: tmpDir, runtime: 'claude' }) - ); - } finally { - if (origHome === undefined) { - delete process.env.HOME; - } else { - process.env.HOME = origHome; - } - if (origUserProfile === undefined) { - delete process.env.USERPROFILE; - } else { - process.env.USERPROFILE = origUserProfile; - } - } - - // With home effort.default = 'low' and the agent currently at 'medium', - // cmdEffortSync must sync exactly 1 agent and set it to 'low'. - assert.equal(result.synced, 1, 'should sync 1 agent whose effort differs from home default'); - assert.equal(result.changes[0].agent, 'gsd-planner'); - assert.equal(result.changes[0].from, 'medium'); - assert.equal(result.changes[0].to, 'low', 'effort must be updated to the home-default value'); - assert.ok( - fs.readFileSync(agentPath, 'utf8').includes('effort: low'), - 'agent file must be rewritten with the home-default effort value' - ); - - cleanup(tmpHome); - cleanup(tmpDir); - }); - - test('CLI dispatcher: positional args after effort sync are rejected', () => { - const result = runCli(['effort', 'sync', 'unexpected-arg']); - assert.notEqual(result.status, 0, 'should exit non-zero on unexpected positional arg'); - assert.ok( - result.stderr.includes('positional') || result.stderr.includes('unexpected-arg'), - `stderr should mention the bad arg; got: ${result.stderr}` - ); - }); - - test('CLI dispatcher: effort sync --apply routes through gsd-tools correctly', () => { - const tmpDir = makeTmpDir('effort-sync-cli-'); - const agentsDir = makeAgentsDir(tmpDir); - const agentPath = path.join(agentsDir, 'gsd-planner.md'); - fs.writeFileSync(agentPath, AGENT_WITH_EFFORT); - writePlanningConfig(tmpDir, { agent_overrides: { 'gsd-planner': 'xhigh' } }); - - const result = runCli( - ['--cwd', tmpDir, 'effort', 'sync', '--apply', '--config-dir', tmpDir], - ); - - assert.equal(result.status, 0, `CLI exited non-zero: ${result.stderr}`); - // gsd-tools may print a startup banner before the JSON payload — parse from the first `{`. - const jsonStart = result.stdout.indexOf('{'); - const output = JSON.parse(result.stdout.slice(jsonStart)); - assert.equal(output.synced, 1); - assert.ok( - fs.readFileSync(agentPath, 'utf8').includes('effort: xhigh'), - 'CLI --apply must write the updated effort value' - ); - - cleanup(tmpDir); - }); -}); diff --git a/tests/feat-68-per-phase-granularity.test.cjs b/tests/feat-68-per-phase-granularity.test.cjs deleted file mode 100644 index 5acfcf4d2..000000000 --- a/tests/feat-68-per-phase-granularity.test.cjs +++ /dev/null @@ -1,510 +0,0 @@ -/** - * Feature test for issue #68 — per-phase granularity. - * - * Adds a `granularities` block to .planning/config.json that accepts phase-type - * keys (planning / discuss / research / execution / verification / - * completion). Resolution precedence: - * - * 1. granularities[phaseType] — per-phase override (enum-guarded) - * 2. top-level `granularity` — global override (new-project / legacy depth) - * 3. planning.granularity — canonical global default (always present post-merge) - * 4. 'standard' — hard fallback - * - * Tests are typed-IR / structural — assert on the value returned by - * resolveGranularityInternal, not stdout/grep. Each test seeds a temp project - * with a fixture .planning/config.json and asserts the resolver picks - * the right granularity for each phase type. - * - * Structure mirrors tests/feat-3023-model-phase-types.test.cjs exactly. - */ - -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -const { test, describe, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const { - resolveGranularityInternal, - VALID_GRANULARITIES, -} = require('../gsd-core/bin/lib/model-resolver.cjs'); -const commands = require('../gsd-core/bin/lib/commands.cjs'); -const { - VALID_PHASE_TYPES, -} = require('../gsd-core/bin/lib/model-profiles.cjs'); -const { isValidConfigKey } = require('../gsd-core/bin/lib/config-schema.cjs'); - -const { createTempDir, runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); -const makeTmp = (prefix) => createTempDir(`gsd-68-${prefix}-`); - -function writeConfig(projectDir, config) { - const planningDir = path.join(projectDir, '.planning'); - fs.mkdirSync(planningDir, { recursive: true }); - fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify(config, null, 2)); -} - -// ─── Export check ──────────────────────────────────────────────────────────── - -describe('#68 exports: resolveGranularityInternal and VALID_GRANULARITIES are exported', () => { - test('resolveGranularityInternal is a function', () => { - assert.equal(typeof resolveGranularityInternal, 'function'); - }); - - test('VALID_GRANULARITIES is a Set containing coarse, standard, fine', () => { - assert.ok(VALID_GRANULARITIES instanceof Set); - assert.deepStrictEqual( - [...VALID_GRANULARITIES].sort(), - ['coarse', 'fine', 'standard'].sort() - ); - }); -}); - -// ─── Schema: granularities. validation ────────────────────────── - -describe('#68 config-schema: granularities. validation', () => { - test('granularities.planning is a valid config key', () => { - assert.equal(isValidConfigKey('granularities.planning'), true); - }); - - test('all six phase-type slots are valid config keys', () => { - for (const slot of ['planning', 'discuss', 'research', 'execution', 'verification', 'completion']) { - assert.equal(isValidConfigKey(`granularities.${slot}`), true, - `granularities.${slot} must be a valid config key`); - } - }); - - test('unknown phase-type is rejected', () => { - assert.equal(isValidConfigKey('granularities.bogus'), false, - 'unknown phase-type must NOT be accepted'); - assert.equal(isValidConfigKey('granularities.deployment'), false, - 'unknown phase-type must NOT be accepted'); - }); - - test('granularities alone (without a slot) is not a valid config-set key — mirrors models behavior', () => { - // Setting the whole block isn't a granular set; users edit JSON directly. - assert.equal(isValidConfigKey('granularities'), false); - }); -}); - -// ─── Resolver behavior: per-phase override wins ────────────────────────────── - -describe('#68 resolver: granularities. overrides global granularity', () => { - let projectDir; - beforeEach(() => { projectDir = makeTmp('resolver'); }); - afterEach(() => { cleanup(projectDir); }); - - test('per-phase override wins: granularities.planning=fine resolves to fine', () => { - writeConfig(projectDir, { - granularity: 'standard', - granularities: { planning: 'fine' }, - }); - assert.equal(resolveGranularityInternal(projectDir, 'planning'), 'fine'); - }); - - test('phase type with no per-phase override falls back to global granularity', () => { - writeConfig(projectDir, { - granularity: 'coarse', - granularities: { planning: 'fine' }, - }); - // 'execution' has no per-phase override → falls back to top-level granularity - assert.equal(resolveGranularityInternal(projectDir, 'execution'), 'coarse'); - }); - - test('all six phase types can be overridden independently', () => { - writeConfig(projectDir, { - granularity: 'standard', - granularities: { - planning: 'fine', - discuss: 'coarse', - research: 'fine', - execution: 'coarse', - verification: 'fine', - completion: 'coarse', - }, - }); - assert.equal(resolveGranularityInternal(projectDir, 'planning'), 'fine'); - assert.equal(resolveGranularityInternal(projectDir, 'discuss'), 'coarse'); - assert.equal(resolveGranularityInternal(projectDir, 'research'), 'fine'); - assert.equal(resolveGranularityInternal(projectDir, 'execution'), 'coarse'); - assert.equal(resolveGranularityInternal(projectDir, 'verification'), 'fine'); - assert.equal(resolveGranularityInternal(projectDir, 'completion'), 'coarse'); - }); -}); - -// ─── Resolver: invalid per-phase value falls through ───────────────────────── - -describe('#68 resolver: invalid per-phase value falls through to global (typo safety)', () => { - let projectDir; - beforeEach(() => { projectDir = makeTmp('invalid'); }); - afterEach(() => { cleanup(projectDir); }); - - test('invalid value ultra falls through to global granularity', () => { - writeConfig(projectDir, { - granularity: 'coarse', - granularities: { planning: 'ultra' }, // not a valid enum value - }); - // Falls through to top-level granularity - assert.equal(resolveGranularityInternal(projectDir, 'planning'), 'coarse'); - }); - - test('invalid value empty-string falls through to global granularity', () => { - writeConfig(projectDir, { - granularity: 'fine', - granularities: { planning: '' }, - }); - assert.equal(resolveGranularityInternal(projectDir, 'planning'), 'fine'); - }); -}); - -// ─── Resolver: malformed granularities block doesn't throw ─────────────────── - -describe('#68 resolver: malformed granularities value does not throw', () => { - let projectDir; - beforeEach(() => { projectDir = makeTmp('malformed'); }); - afterEach(() => { cleanup(projectDir); }); - - test('granularities as a string does not throw, returns global fallback', () => { - writeConfig(projectDir, { - granularity: 'coarse', - granularities: 'fine', // string, not an object - }); - assert.doesNotThrow(() => resolveGranularityInternal(projectDir, 'planning')); - assert.equal(resolveGranularityInternal(projectDir, 'planning'), 'coarse'); - }); - - test('granularities as null does not throw, returns global fallback', () => { - writeConfig(projectDir, { - granularity: 'coarse', - granularities: null, - }); - assert.doesNotThrow(() => resolveGranularityInternal(projectDir, 'planning')); - assert.equal(resolveGranularityInternal(projectDir, 'planning'), 'coarse'); - }); - - test('granularities as an array does not throw, returns global fallback', () => { - writeConfig(projectDir, { - granularity: 'fine', - granularities: ['fine'], - }); - assert.doesNotThrow(() => resolveGranularityInternal(projectDir, 'planning')); - assert.equal(resolveGranularityInternal(projectDir, 'planning'), 'fine'); - }); -}); - -// ─── Backward-compat (Hyrum): no granularities key mirrors pre-feature behavior - -describe('#68 backward-compat: no granularities key resolves identically to pre-feature global', () => { - let projectDir; - beforeEach(() => { projectDir = makeTmp('compat'); }); - afterEach(() => { cleanup(projectDir); }); - - test('top-level granularity=fine resolves to fine for all six phase types (no granularities key)', () => { - writeConfig(projectDir, { - granularity: 'fine', - }); - for (const phaseType of ['planning', 'discuss', 'research', 'execution', 'verification', 'completion']) { - assert.equal(resolveGranularityInternal(projectDir, phaseType), 'fine', - `${phaseType} must resolve to fine`); - } - }); - - test('no granularity key at all → all phase types resolve to standard (canonical default)', () => { - writeConfig(projectDir, {}); - for (const phaseType of ['planning', 'discuss', 'research', 'execution', 'verification', 'completion']) { - assert.equal(resolveGranularityInternal(projectDir, phaseType), 'standard', - `${phaseType} must resolve to standard (canonical default)`); - } - }); -}); - -// ─── Global precedence chain ───────────────────────────────────────────────── - -describe('#68 resolver: global fallback precedence chain', () => { - let projectDir; - beforeEach(() => { projectDir = makeTmp('precedence'); }); - afterEach(() => { cleanup(projectDir); }); - - test('top-level granularity honored when present', () => { - writeConfig(projectDir, { - granularity: 'coarse', - planning: { granularity: 'fine' }, // planning.granularity is lower precedence - }); - assert.equal(resolveGranularityInternal(projectDir, 'execution'), 'coarse'); - }); - - test('planning.granularity honored when top-level granularity absent', () => { - writeConfig(projectDir, { - planning: { granularity: 'fine' }, - }); - assert.equal(resolveGranularityInternal(projectDir, 'execution'), 'fine'); - }); - - test('hard default standard when neither top-level nor planning.granularity present', () => { - writeConfig(projectDir, {}); - assert.equal(resolveGranularityInternal(projectDir, 'execution'), 'standard'); - }); - - test('per-phase override beats all global sources', () => { - writeConfig(projectDir, { - granularity: 'coarse', - planning: { granularity: 'coarse' }, - granularities: { planning: 'fine' }, - }); - assert.equal(resolveGranularityInternal(projectDir, 'planning'), 'fine'); - }); -}); - -// ─── VALID_PHASE_TYPES consistency ────────────────────────────────────────── - -describe('#68 VALID_PHASE_TYPES covers all six slots used by granularities', () => { - test('the six granularities slots are all valid phase types', () => { - for (const slot of ['planning', 'discuss', 'research', 'execution', 'verification', 'completion']) { - assert.ok(VALID_PHASE_TYPES.has(slot), - `${slot} must be in VALID_PHASE_TYPES`); - } - }); -}); - -// ─── CMD-level: cmdResolveGranularity export + CLI behavior ───────────────── -// Mirrors the resolve-model command tests in tests/commands.test.cjs (CMD-03). - -describe('#68 exports: cmdResolveGranularity is exported as a function', () => { - test('cmdResolveGranularity is a function', () => { - assert.equal(typeof commands.cmdResolveGranularity, 'function'); - }); -}); - -describe('#68 resolve-granularity command: CLI behavior', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempProject(); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('(a) missing phase-type arg → command exits with error mentioning phase-type required', () => { - const result = runGsdTools('resolve-granularity', tmpDir); - assert.ok(!result.success, 'should fail without phase-type'); - assert.ok(result.error.includes('phase-type required'), `error should mention phase-type required; got: ${result.error}`); - }); - - test('(b) unknown phase type → result includes unknown_phase_type: true', () => { - const result = runGsdTools('resolve-granularity nonexistent-phase', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - const output = JSON.parse(result.output); - assert.strictEqual(output.unknown_phase_type, true, 'should flag unknown phase type'); - assert.ok(output.granularity, 'should still return a granularity'); - }); - - test('(c) valid phase type with granularities override → returns override granularity', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'config.json'), - JSON.stringify({ - granularity: 'standard', - granularities: { planning: 'fine' }, - }) - ); - const result = runGsdTools('resolve-granularity planning', tmpDir, { HOME: tmpDir }); - assert.ok(result.success, `Command failed: ${result.error}`); - const output = JSON.parse(result.output); - assert.strictEqual(output.granularity, 'fine', 'granularities.planning override should win'); - assert.strictEqual(output.phase_type, 'planning'); - assert.strictEqual(output.unknown_phase_type, undefined, 'known phase type must not have unknown_phase_type'); - }); -}); - -// ─── #703 CLI override: --granularity flag ──────────────────────────────────── - -describe('#703 resolveGranularityInternal: CLI override param (3rd arg)', () => { - let projectDir; - beforeEach(() => { projectDir = makeTmp('cli-override'); }); - afterEach(() => { cleanup(projectDir); }); - - test('override fine beats per-phase config granularities.planning=coarse', () => { - writeConfig(projectDir, { - granularity: 'standard', - granularities: { planning: 'coarse' }, - }); - assert.equal(resolveGranularityInternal(projectDir, 'planning', 'fine'), 'fine', - 'CLI override must beat per-phase config'); - }); - - test('override coarse beats top-level granularity=fine', () => { - writeConfig(projectDir, { - granularity: 'fine', - }); - assert.equal(resolveGranularityInternal(projectDir, 'execution', 'coarse'), 'coarse', - 'CLI override must beat top-level granularity'); - }); - - test('override standard beats planning.granularity=fine global fallback (regardless of phase type)', () => { - writeConfig(projectDir, { - planning: { granularity: 'fine' }, - }); - assert.equal(resolveGranularityInternal(projectDir, 'execution', 'standard'), 'standard', - 'CLI override must beat planning.granularity fallback'); - }); - - test("override '' (empty string) falls through to config chain", () => { - writeConfig(projectDir, { - granularity: 'coarse', - }); - assert.equal(resolveGranularityInternal(projectDir, 'planning', ''), 'coarse', - 'empty-string override must fall through to config chain'); - }); - - test('override undefined falls through to config chain', () => { - writeConfig(projectDir, { - granularity: 'fine', - }); - assert.equal(resolveGranularityInternal(projectDir, 'planning', undefined), 'fine', - 'undefined override must fall through to config chain'); - }); - - test('override null falls through to config chain', () => { - writeConfig(projectDir, { - granularity: 'coarse', - }); - assert.equal(resolveGranularityInternal(projectDir, 'planning', null), 'coarse', - 'null override must fall through to config chain'); - }); - - test('invalid override value falls through to config chain (not rejected in resolver)', () => { - writeConfig(projectDir, { - granularity: 'standard', - }); - // Invalid override reaches resolver → falls through (validation is CLI boundary's job) - assert.equal(resolveGranularityInternal(projectDir, 'planning', 'ultra'), 'standard', - 'invalid override must fall through to config chain in resolver'); - }); -}); - -describe('#703 cmdResolveGranularity: --granularity CLI flag', () => { - let tmpDir; - beforeEach(() => { tmpDir = createTempProject(); }); - afterEach(() => { cleanup(tmpDir); }); - - test('--granularity fine overrides config chain via CLI tool', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'config.json'), - JSON.stringify({ granularity: 'coarse' }) - ); - const result = runGsdTools('resolve-granularity planning --granularity fine', tmpDir, { HOME: tmpDir }); - assert.ok(result.success, `Command failed: ${result.error}`); - const output = JSON.parse(result.output); - assert.strictEqual(output.granularity, 'fine', '--granularity fine must override config coarse'); - }); - - test('--granularity coarse overrides per-phase granularities.planning=fine', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'config.json'), - JSON.stringify({ - granularity: 'standard', - granularities: { planning: 'fine' }, - }) - ); - const result = runGsdTools('resolve-granularity planning --granularity coarse', tmpDir, { HOME: tmpDir }); - assert.ok(result.success, `Command failed: ${result.error}`); - const output = JSON.parse(result.output); - assert.strictEqual(output.granularity, 'coarse', '--granularity coarse must beat per-phase fine'); - }); - - test('invalid --granularity value exits with error', () => { - const result = runGsdTools('resolve-granularity planning --granularity ultra', tmpDir); - assert.ok(!result.success, 'should fail with invalid granularity'); - assert.ok( - result.error.includes('ultra') || result.error.includes('invalid'), - `error should mention invalid value; got: ${result.error}` - ); - }); -}); - -// ─── #703 end-to-end: init.plan-phase path forwards and resolves granularity ── -// -// Fixture mirrors tests/pattern-mapper.test.cjs: createTempProject() + -// minimal STATE.md + ROADMAP.md + phase directory — just enough for -// cmdInitPlanPhase to succeed without agents / git. - -function makeInitPlanPhaseFixture(prefix) { - const tmpDir = createTempProject(prefix); - const planningDir = path.join(tmpDir, '.planning'); - fs.writeFileSync(path.join(planningDir, 'STATE.md'), [ - '# State', - '', - '## Current Phase', - 'Phase 1 — Foundation', - ].join('\n')); - fs.writeFileSync(path.join(planningDir, 'ROADMAP.md'), [ - '# Roadmap', - '', - '## Phase 1: Foundation', - 'Build the foundation.', - '**Status:** Planning', - '**Requirements:** [FOUND-01]', - ].join('\n')); - fs.mkdirSync(path.join(planningDir, 'phases', '01-foundation'), { recursive: true }); - return tmpDir; -} - -describe('#703 init.plan-phase end-to-end: granularity resolution via CLI', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = makeInitPlanPhaseFixture('gsd-68-e2e-'); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('(1) --granularity fine overrides config granularities.planning=coarse end-to-end', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'config.json'), - JSON.stringify({ granularity: 'standard', granularities: { planning: 'coarse' } }) - ); - const result = runGsdTools('init plan-phase 1 --granularity fine', tmpDir, { HOME: tmpDir }); - assert.ok(result.success, `init plan-phase failed: ${result.error}`); - const data = JSON.parse(result.output); - assert.strictEqual(data.granularity, 'fine', - '--granularity fine override must win over granularities.planning=coarse end-to-end'); - }); - - test('(2) no flag + config granularities.planning=fine → granularity=fine (Fix A: per-phase-type honored)', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'config.json'), - JSON.stringify({ granularity: 'coarse', granularities: { planning: 'fine' } }) - ); - const result = runGsdTools('init plan-phase 1', tmpDir, { HOME: tmpDir }); - assert.ok(result.success, `init plan-phase failed: ${result.error}`); - const data = JSON.parse(result.output); - assert.strictEqual(data.granularity, 'fine', - 'granularities.planning=fine must be honored (phaseType=planning in resolveGranularityInternal)'); - }); - - test('(3) no flag + global granularity=coarse (no granularities.planning) → granularity=coarse', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'config.json'), - JSON.stringify({ granularity: 'coarse' }) - ); - const result = runGsdTools('init plan-phase 1', tmpDir, { HOME: tmpDir }); - assert.ok(result.success, `init plan-phase failed: ${result.error}`); - const data = JSON.parse(result.output); - assert.strictEqual(data.granularity, 'coarse', - 'global granularity=coarse must be returned when no granularities.planning is set'); - }); - - test('(4) --granularity ultra → command errors (invalid value rejected on plan-phase path)', () => { - const result = runGsdTools('init plan-phase 1 --granularity ultra', tmpDir, { HOME: tmpDir }); - assert.ok(!result.success, 'should fail with invalid granularity ultra'); - assert.ok( - result.error.includes('ultra') || result.error.includes('invalid'), - `error should mention invalid value; got: ${result.error}` - ); - }); -}); diff --git a/tests/fix-1445-999x-backlog-excluded-from-total-phases.test.cjs b/tests/fix-1445-999x-backlog-excluded-from-total-phases.test.cjs deleted file mode 100644 index 00b4d169a..000000000 --- a/tests/fix-1445-999x-backlog-excluded-from-total-phases.test.cjs +++ /dev/null @@ -1,295 +0,0 @@ -'use strict'; -/** - * Regression test for bug #1445: - * 999.x backlog phases must not be counted toward total_phases. - * - * Root cause: - * deriveProgressFromRoadmap (phase-lifecycle.cts) counted ALL data rows - * matching /^\|\s*\d+/ in the progress table, including 999.x backlog rows. - * Similarly, state.cts's roadmapPhaseCount loop (via extractCurrentMilestone) - * counted 999.x phase headings because it only checked /\d/.test(m[1]). - * - * Fix: - * Both sites now test /^999(?:\.|$)/.test(token) and skip matching rows. - * Mirrors the existing init.cts /^999(?:\.|$)/ filter. - * - * Scenarios: - * A. deriveProgressFromRoadmap with a progress table containing a 999.x row. - * B. state json total_phases via extractCurrentMilestone / roadmapPhaseCount. - * - * Follow-up #1580: the same `^999` (and Phase 0) sentinel exclusion was missing - * in two more code paths — `milestone complete`'s unstarted-phase guard - * (src/milestone.cts) and `roadmap analyze`'s next_phase routing + phase_count - * (src/roadmap.cts). Scenarios C and D below cover those. - * - * C. milestone complete is NOT blocked by a Phase 999 backlog heading. - * D. roadmap analyze never routes next_phase to 999 / never counts it. - */ - -const { describe, test, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); -const { deriveProgressFromRoadmap } = require('../gsd-core/bin/lib/phase-lifecycle.cjs'); - -// ─── Scenario A: deriveProgressFromRoadmap unit test ──────────────────────── - -describe('bug #1445 — deriveProgressFromRoadmap excludes 999.x rows', () => { - test('3 real phases + 1 999.x backlog row → total_phases: 3, not 4', () => { - const roadmap = [ - '## Milestone v1.0: Test', - '', - '| Phase | Plans | Status | Completed |', - '| --- | --- | --- | --- |', - '| 1. Alpha | 2/2 | Complete | ✅ |', - '| 2. Beta | 1/2 | In Progress | |', - '| 3. Gamma | 0/1 | Planned | |', - '| 999.1 Backlog: Future Idea | 0/0 | Backlog | |', - ].join('\n'); - - const result = deriveProgressFromRoadmap(roadmap); - assert.equal( - result.totalPhases, - 3, - `total_phases must be 3 (not 4) — 999.1 backlog row must be excluded. Got ${result.totalPhases}`, - ); - assert.equal( - result.completedPhases, - 1, - `completed_phases must be 1. Got ${result.completedPhases}`, - ); - }); - - test('999 exact (no dot) row is also excluded', () => { - const roadmap = [ - '## Milestone v1.0: Test', - '', - '| Phase | Plans | Status | Completed |', - '| --- | --- | --- | --- |', - '| 1. Alpha | 1/1 | Complete | ✅ |', - '| 2. Beta | 1/1 | Complete | ✅ |', - '| 999 Backlog | 0/0 | Backlog | |', - ].join('\n'); - - const result = deriveProgressFromRoadmap(roadmap); - assert.equal( - result.totalPhases, - 2, - `total_phases must be 2 (not 3) — 999 row must be excluded. Got ${result.totalPhases}`, - ); - assert.equal( - result.completedPhases, - 2, - `completed_phases must be 2. Got ${result.completedPhases}`, - ); - }); - - test('all-backlog table yields null total_phases (no real phases)', () => { - const roadmap = [ - '## Milestone v1.0: Test', - '', - '| Phase | Plans | Status | Completed |', - '| --- | --- | --- | --- |', - '| 999.1 Future A | 0/0 | Backlog | |', - '| 999.2 Future B | 0/0 | Backlog | |', - ].join('\n'); - - const result = deriveProgressFromRoadmap(roadmap); - assert.equal( - result.totalPhases, - null, - `total_phases must be null when the only rows are 999.x backlog. Got ${result.totalPhases}`, - ); - }); -}); - -// ─── Scenario B: state json total_phases via roadmapPhaseCount ─────────────── - -describe('bug #1445 — state json excludes 999.x phase headings from total_phases', () => { - let tmpDir; - - const ROADMAP = [ - '## Milestone v1.0: Test Milestone', - '', - '### Phase 01: Alpha', - '**Goal:** first', - '', - '### Phase 02: Beta', - '**Goal:** second', - '', - '### Phase 03: Gamma', - '**Goal:** third', - '', - '### Phase 999.1: Backlog Item A', - '**Goal:** future idea, not counted', - '', - '### Phase 999.2: Backlog Item B', - '**Goal:** another future idea', - ].join('\n'); - - beforeEach(() => { - tmpDir = createTempProject('bug-1445-'); - const planning = path.join(tmpDir, '.planning'); - fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP, 'utf-8'); - fs.writeFileSync( - path.join(planning, 'STATE.md'), - [ - '---', - 'gsd_state_version: 1.0', - 'milestone: v1.0', - 'status: executing', - '---', - '', - '# GSD State', - '', - '## Configuration', - 'Current Phase: 1', - 'Status: Executing Phase 1', - 'Last Activity: 2026-01-01', - ].join('\n'), - 'utf-8', - ); - fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8'); - - for (const d of ['01-alpha', '02-beta', '03-gamma']) { - const dir = path.join(planning, 'phases', d); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(path.join(dir, 'PLAN.md'), '# Plan\n', 'utf-8'); - } - // 999.x dirs should exist on disk but must not inflate total_phases - for (const d of ['999.1-backlog-a', '999.2-backlog-b']) { - fs.mkdirSync(path.join(planning, 'phases', d), { recursive: true }); - } - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('state json total_phases is 3, not 5 (999.x dirs and headings excluded)', () => { - const result = runGsdTools(['state', 'json'], tmpDir); - assert.ok(result.success, `state json failed: ${result.error}`); - const state = JSON.parse(result.output); - assert.ok(state.progress, 'state json must return a progress block'); - assert.equal( - state.progress.total_phases, - 3, - `total_phases must be 3 (not 5). 999.x backlog phases must be excluded. Got ${state.progress.total_phases}`, - ); - }); -}); - -// ─── Scenario C: milestone complete not blocked by a 999 backlog heading ───── - -describe('fix #1580 — milestone complete ignores the 999 backlog sentinel', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempProject('fix-1580-mc-'); - const planning = path.join(tmpDir, '.planning'); - // One real, on-disk phase + a directory-less Phase 999 backlog heading. - fs.writeFileSync( - path.join(planning, 'ROADMAP.md'), - [ - '# Roadmap v1.0', - '## v1.0 Milestone', - '## Phases', - '- [x] **Phase 1: Foundation**', - '## Phase Details', - '### Phase 1: Foundation', - '**Goal:** build it', - '### Phase 999: Backlog / Someday', - '**Goal:** deferred, never executed', - ].join('\n'), - 'utf-8', - ); - fs.writeFileSync( - path.join(planning, 'STATE.md'), - `---\nmilestone: v1.0\n---\n# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`, - 'utf-8', - ); - const dir = path.join(planning, 'phases', '01-foundation'); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(path.join(dir, 'PLAN.md'), '# Plan\n', 'utf-8'); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('completes WITHOUT --force despite a Phase 999 backlog heading', () => { - const result = runGsdTools( - ['milestone', 'complete', 'v1.0', '--name', 'Regression'], - tmpDir, - ); - assert.ok( - result.success, - `milestone complete must not be blocked by the 999 sentinel; got error: ${result.error}`, - ); - assert.ok( - !/Cannot mark milestone complete/.test(result.error || ''), - `the unstarted-phase guard must not fire on Phase 999. Got: ${result.error}`, - ); - }); -}); - -// ─── Scenario D: roadmap analyze never routes/ counts the 999 sentinel ──────── - -describe('fix #1580 — roadmap analyze excludes the 999 backlog sentinel', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempProject('fix-1580-ra-'); - const planning = path.join(tmpDir, '.planning'); - fs.writeFileSync( - path.join(planning, 'ROADMAP.md'), - [ - '# Roadmap v1.0', - '## v1.0 Milestone', - '## Phases', - '- [x] **Phase 1: Foundation**', - '## Phase Details', - '### Phase 1: Foundation', - '**Goal:** build it', - '### Phase 999: Backlog / Someday', - '**Goal:** deferred, never executed', - ].join('\n'), - 'utf-8', - ); - fs.writeFileSync( - path.join(planning, 'STATE.md'), - `---\nmilestone: v1.0\n---\n# State\n`, - 'utf-8', - ); - const dir = path.join(planning, 'phases', '01-foundation'); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(path.join(dir, 'PLAN.md'), '# Plan\n', 'utf-8'); - fs.writeFileSync(path.join(dir, 'SUMMARY.md'), '# Summary\n', 'utf-8'); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('next_phase is never 999 and phase_count excludes the sentinel', () => { - const result = runGsdTools(['roadmap', 'analyze', '--raw'], tmpDir); - assert.ok(result.success, `roadmap analyze failed: ${result.error}`); - const analysis = JSON.parse(result.output); - assert.notEqual( - String(analysis.next_phase), - '999', - `next_phase must never route to the 999 backlog sentinel. Got ${analysis.next_phase}`, - ); - assert.equal( - analysis.phase_count, - 1, - `phase_count must exclude the 999 sentinel (expected 1). Got ${analysis.phase_count}`, - ); - assert.ok( - !(analysis.phases || []).some(p => String(p.number) === '999'), - 'the phases array must not include the 999 backlog sentinel', - ); - }); -}); diff --git a/tests/fix-1446-total-phases-corrects-downward.test.cjs b/tests/fix-1446-total-phases-corrects-downward.test.cjs deleted file mode 100644 index ae0c72bf8..000000000 --- a/tests/fix-1446-total-phases-corrects-downward.test.cjs +++ /dev/null @@ -1,156 +0,0 @@ -'use strict'; -/** - * Regression test for bug #1446: - * total_phases must correct downward when re-derived; shouldPreserveExistingProgress - * must NOT include total_phases in its ratchet check. - * - * Root cause: - * shouldPreserveExistingProgress (state-document.cts) returned true when - * existingProgress.total_phases > derivedProgress.total_phases, making the - * stored value sticky even when it was wrong (e.g. counted backlog phases). - * - * Fix: - * total_phases is removed from the "existing exceeds derived" check. - * Only completed_phases, total_plans, and completed_plans keep ratchet behaviour. - * - * Scenarios: - * A. shouldPreserveExistingProgress unit test — returns false when only total_phases differs. - * B. state sync re-derives a lower total_phases and writes the new value. - */ - -const { describe, test, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); -const { shouldPreserveExistingProgress } = require('../gsd-core/bin/lib/state-document.cjs'); - -// ─── Scenario A: unit test ─────────────────────────────────────────────────── - -describe('bug #1446 — shouldPreserveExistingProgress does not ratchet total_phases', () => { - test('existing total_phases:10 > derived total_phases:7 → returns false (no ratchet)', () => { - const existing = { total_phases: 10, completed_phases: 3, total_plans: 6, completed_plans: 3 }; - const derived = { total_phases: 7, completed_phases: 3, total_plans: 6, completed_plans: 3 }; - assert.equal( - shouldPreserveExistingProgress(existing, derived), - false, - 'total_phases downward correction must NOT trigger shouldPreserveExistingProgress', - ); - }); - - test('existing completed_phases:5 > derived completed_phases:2 → returns true (ratchet still active)', () => { - const existing = { total_phases: 7, completed_phases: 5, total_plans: 6, completed_plans: 3 }; - const derived = { total_phases: 7, completed_phases: 2, total_plans: 6, completed_plans: 3 }; - assert.equal( - shouldPreserveExistingProgress(existing, derived), - true, - 'completed_phases ratchet must still work', - ); - }); - - test('existing total_phases:10 > derived:7 AND completed_phases matches → false (total_phases alone does not preserve)', () => { - const existing = { total_phases: 10, completed_phases: 3 }; - const derived = { total_phases: 7, completed_phases: 3 }; - assert.equal( - shouldPreserveExistingProgress(existing, derived), - false, - 'only-total_phases discrepancy must not trigger preservation', - ); - }); - - test('all derived values equal existing → returns false', () => { - const existing = { total_phases: 7, completed_phases: 3, total_plans: 6, completed_plans: 3 }; - const derived = { total_phases: 7, completed_phases: 3, total_plans: 6, completed_plans: 3 }; - assert.equal(shouldPreserveExistingProgress(existing, derived), false); - }); -}); - -// ─── Scenario B: end-to-end state sync overwrites inflated total_phases ────── - -describe('bug #1446 — state sync writes corrected (lower) total_phases', () => { - let tmpDir; - - // ROADMAP has 3 real phases only (no 999.x). - const ROADMAP = [ - '## Milestone v1.0: Test', - '', - '### Phase 01: Alpha', - '**Goal:** alpha', - '', - '### Phase 02: Beta', - '**Goal:** beta', - '', - '### Phase 03: Gamma', - '**Goal:** gamma', - ].join('\n'); - - beforeEach(() => { - tmpDir = createTempProject('bug-1446-'); - const planning = path.join(tmpDir, '.planning'); - fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP, 'utf-8'); - - // STATE.md has a stale inflated total_phases:10 in frontmatter. - fs.writeFileSync( - path.join(planning, 'STATE.md'), - [ - '---', - 'gsd_state_version: 1.0', - 'milestone: v1.0', - 'status: executing', - 'progress:', - ' total_phases: 10', - ' completed_phases: 2', - ' total_plans: 6', - ' completed_plans: 4', - ' percent: 40', - '---', - '', - '# GSD State', - '', - '## Configuration', - 'Current Phase: 3', - 'Status: Executing Phase 3', - 'Last Activity: 2026-01-01', - 'Progress: [████░░░░░░] 40%', - ].join('\n'), - 'utf-8', - ); - fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8'); - - for (const d of ['01-alpha', '02-beta', '03-gamma']) { - const dir = path.join(planning, 'phases', d); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(path.join(dir, 'PLAN.md'), '# Plan\n', 'utf-8'); - // Mark 01 and 02 as complete (2 summaries) - if (d !== '03-gamma') { - fs.writeFileSync(path.join(dir, 'PLAN-SUMMARY.md'), '# Summary\n', 'utf-8'); - } - } - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('state sync corrects total_phases from 10 to 3', () => { - const syncResult = runGsdTools(['state', 'sync'], tmpDir); - assert.ok(syncResult.success, `state sync failed: ${syncResult.error}`); - - const jsonResult = runGsdTools(['state', 'json'], tmpDir); - assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`); - const state = JSON.parse(jsonResult.output); - - assert.ok(state.progress, 'state json must return a progress block'); - assert.equal( - state.progress.total_phases, - 3, - `total_phases must be corrected to 3 (derived), not kept at 10 (stale). Got ${state.progress.total_phases}`, - ); - // completed_phases ratchet still works: existing 2 ≥ disk-derived → keep 2 - assert.ok( - state.progress.completed_phases >= 2, - `completed_phases must be at least 2 (ratchet). Got ${state.progress.completed_phases}`, - ); - }); -}); diff --git a/tests/fix-1514-retired-phase-excluded-from-total-phases.test.cjs b/tests/fix-1514-retired-phase-excluded-from-total-phases.test.cjs deleted file mode 100644 index 3d106fc75..000000000 --- a/tests/fix-1514-retired-phase-excluded-from-total-phases.test.cjs +++ /dev/null @@ -1,352 +0,0 @@ -'use strict'; -/** - * Regression test for bug #1514: - * A retired/folded phase (struck through in ROADMAP, marked `[x]`, with a - * directory but no completion artifact) must NOT be counted in - * progress.total_phases. Otherwise it inflates the denominator without ever - * satisfying the numerator (no SUMMARY → never "completed"), freezing a - * fully-shipped milestone below 100%. - * - * Root cause: - * buildStateFrontmatter (state.cts) derived total_phases from - * max(phaseDirs.length, roadmapPhaseCount) — both of which counted the - * retired phase (its directory and its `### Phase NN:` heading) — while - * completed_phases came from a disk SUMMARY scan that the retired phase - * can never satisfy. Same counting family as #549 / #500 / #1445. - * - * Fix: - * buildStateFrontmatter now extracts retired phase numbers from the GFM - * strikethrough in the current-milestone ROADMAP scope and excludes them - * from BOTH the disk phase-dir set and the heading count, so a retired - * phase counts toward neither denominator nor numerator. - * - * Why integration (state json) not a unit test: the bug only manifests in the - * assembled progress block a shipped milestone actually writes to STATE.md, so - * the test reproduces that artifact rather than a helper in isolation. - */ - -const { describe, test, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); -const fc = require('./helpers/fast-check-setup.cjs'); -const { _extractRetiredPhaseNumbers } = require('../gsd-core/bin/lib/state.cjs'); -const { normalizePhaseName } = require('../gsd-core/bin/lib/phase-id.cjs'); - -// Six phases, all shipped, except Phase 04 which is retired/folded into 05. -// Phases 01-03,05,06 have PLAN+SUMMARY (complete); Phase 04 keeps a directory -// but no work (retired). `complete` flags which dirs get PLAN+SUMMARY. -function seedProject(prefix, roadmap, completeDirs) { - const tmpDir = createTempProject(prefix); - const planning = path.join(tmpDir, '.planning'); - fs.writeFileSync(path.join(planning, 'ROADMAP.md'), roadmap, 'utf-8'); - fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8'); - fs.writeFileSync( - path.join(planning, 'STATE.md'), - [ - '---', - 'gsd_state_version: 1.0', - 'milestone: v1.0', - 'status: executing', - '---', - '', - '# GSD State', - '', - '## Configuration', - 'Current Phase: 6', - 'Status: shipped', - 'Last Activity: 2026-06-01', - ].join('\n'), - 'utf-8', - ); - const allDirs = ['01-alpha', '02-beta', '03-gamma', '04-delta', '05-epsilon', '06-zeta']; - for (const d of allDirs) { - const dir = path.join(planning, 'phases', d); - fs.mkdirSync(dir, { recursive: true }); - if (completeDirs.includes(d)) { - fs.writeFileSync(path.join(dir, 'PLAN.md'), '# Plan\n', 'utf-8'); - fs.writeFileSync(path.join(dir, 'SUMMARY.md'), '# Summary\n', 'utf-8'); - } - } - return tmpDir; -} - -const PHASE_DETAILS = [ - '### Phase 01: Alpha', '**Goal:** a', '', - '### Phase 02: Beta', '**Goal:** b', '', - '### Phase 03: Gamma', '**Goal:** c', '', - '### Phase 04: Delta', '**Goal:** GOAL_04', '', - '### Phase 05: Epsilon', '**Goal:** e', '', - '### Phase 06: Zeta', '**Goal:** f', -]; - -function roadmap(checklist04, goal04) { - return [ - '## Milestone v1.0: Repro', - '', - '### Phases', - '- [x] **Phase 01: Alpha** — done', - '- [x] **Phase 02: Beta** — done', - '- [x] **Phase 03: Gamma** — done', - checklist04, - '- [x] **Phase 05: Epsilon** — done', - '- [x] **Phase 06: Zeta** — done', - '', - ...PHASE_DETAILS.map((l) => (l === '**Goal:** GOAL_04' ? `**Goal:** ${goal04}` : l)), - ].join('\n'); -} - -const ALL_COMPLETE = ['01-alpha', '02-beta', '03-gamma', '05-epsilon', '06-zeta']; - -describe('bug #1514 — retired/folded phase excluded from progress.total_phases', () => { - let tmpDir; - afterEach(() => { - if (tmpDir) cleanup(tmpDir); - tmpDir = undefined; - }); - - test('struck `[x] ~~Phase 04~~ — folded into Phase 05` → 5/5, percent 100 (not 5/6, 83)', () => { - const rm = roadmap( - '- [x] ~~**Phase 04: Delta**~~ — folded into Phase 05; number retired', - 'folded into Phase 05', - ); - tmpDir = seedProject('bug-1514-a-', rm, ALL_COMPLETE); - const result = runGsdTools(['state', 'json'], tmpDir); - assert.ok(result.success, `state json failed: ${result.error}`); - const { progress } = JSON.parse(result.output); - assert.equal(progress.total_phases, 5, `total_phases must exclude the retired phase. Got ${progress.total_phases}`); - assert.equal(progress.completed_phases, 5, `completed_phases must be 5. Got ${progress.completed_phases}`); - assert.equal(progress.percent, 100, `shipped milestone must reach 100%. Got ${progress.percent}`); - }); - - test('fold TARGET is not retired: a struck goal line `~~folded into Phase 05~~` must not drop Phase 05', () => { - // Phase 04 retired via checklist; Phase 04 *goal* also struck and mentions - // the fold target. The target (Phase 05) must remain a counted phase. - const rm = roadmap( - '- [x] ~~**Phase 04: Delta**~~ — folded into Phase 05; number retired', - '~~folded into Phase 05; retired~~', - ); - tmpDir = seedProject('bug-1514-b-', rm, ALL_COMPLETE); - const result = runGsdTools(['state', 'json'], tmpDir); - assert.ok(result.success, `state json failed: ${result.error}`); - const { progress } = JSON.parse(result.output); - assert.equal(progress.total_phases, 5, `only Phase 04 is retired; Phase 05 must still count. Got ${progress.total_phases}`); - assert.equal(progress.completed_phases, 5, `completed_phases must be 5. Got ${progress.completed_phases}`); - assert.equal(progress.percent, 100, `Got ${progress.percent}`); - }); - - test('regression: no strikethrough → all 6 phases counted (6/6, 100)', () => { - const rm = roadmap('- [x] **Phase 04: Delta** — done', 'd'); - tmpDir = seedProject('bug-1514-c-', rm, [...ALL_COMPLETE, '04-delta']); - const result = runGsdTools(['state', 'json'], tmpDir); - assert.ok(result.success, `state json failed: ${result.error}`); - const { progress } = JSON.parse(result.output); - assert.equal(progress.total_phases, 6, `no retired phase: all 6 counted. Got ${progress.total_phases}`); - assert.equal(progress.completed_phases, 6, `Got ${progress.completed_phases}`); - assert.equal(progress.percent, 100, `Got ${progress.percent}`); - }); - - // `state sync --verify` is the SECOND counting path (cmdStateSync). Before the - // fix it re-derived the same inflated denominator and reported "no drift", - // so a manual STATE edit was the only recourse (#1514). It must now agree - // with state json and drive the stuck 83% Progress field to 100%. - test('state sync --verify drives a stuck 83% Progress to 100% (cmdStateSync path)', () => { - const rm = roadmap( - '- [x] ~~**Phase 04: Delta**~~ — folded into Phase 05; number retired', - 'folded into Phase 05', - ); - tmpDir = seedProject('bug-1514-sync-', rm, ALL_COMPLETE); - // Seed a stuck Progress line that the inflated denominator would "agree" with. - const statePath = path.join(tmpDir, '.planning', 'STATE.md'); - fs.appendFileSync(statePath, '\nProgress: [████████░░] 83%\n', 'utf-8'); - const result = runGsdTools(['state', 'sync', '--verify'], tmpDir); - assert.ok(result.success, `state sync --verify failed: ${result.error}`); - const { changes } = JSON.parse(result.output); - const progressChange = (changes || []).find((c) => /Progress:/.test(c)); - assert.ok(progressChange, `expected a Progress drift, got changes: ${JSON.stringify(changes)}`); - assert.match(progressChange, /-> .*100%/, `sync must want 100%, got: ${progressChange}`); - }); -}); - -// ─── Generic seeder for non-canonical phase shapes ────────────────────────── - -/** - * Seed a project from explicit phase specs so project-code, decimal, - * no-directory, and shipped-then-retired shapes can be exercised. - * spec: { id, retired?, dir?, shipped? } - * id — ROADMAP phase id (e.g. '04', '05.1', 'PROJ-42') - * retired — strike the checklist entry (folded/retired) - * dir — directory name to create (omit → no directory) - * shipped — write PLAN+SUMMARY into the directory (complete) - */ -function seedFromSpecs(prefix, specs) { - const tmpDir = createTempProject(prefix); - const planning = path.join(tmpDir, '.planning'); - const checklist = specs.map((s) => - s.retired - ? `- [x] ~~**Phase ${s.id}: P${s.id}**~~ — retired` - : `- [x] **Phase ${s.id}: P${s.id}** — done`, - ); - const details = specs.flatMap((s) => [`### Phase ${s.id}: P${s.id}`, '**Goal:** g', '']); - const roadmapText = ['## Milestone v1.0: Specs', '', '### Phases', ...checklist, '', ...details].join('\n'); - fs.writeFileSync(path.join(planning, 'ROADMAP.md'), roadmapText, 'utf-8'); - fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8'); - fs.writeFileSync( - path.join(planning, 'STATE.md'), - ['---', 'gsd_state_version: 1.0', 'milestone: v1.0', 'status: executing', '---', '', '# GSD State', '', '## Configuration', 'Current Phase: 1'].join('\n'), - 'utf-8', - ); - for (const s of specs) { - if (!s.dir) continue; - const dir = path.join(planning, 'phases', s.dir); - fs.mkdirSync(dir, { recursive: true }); - if (s.shipped) { - fs.writeFileSync(path.join(dir, 'PLAN.md'), '# Plan\n', 'utf-8'); - fs.writeFileSync(path.join(dir, 'SUMMARY.md'), '# Summary\n', 'utf-8'); - } - } - return tmpDir; -} - -describe('bug #1514 — retired exclusion across phase shapes', () => { - let tmpDir; - afterEach(() => { - if (tmpDir) cleanup(tmpDir); - tmpDir = undefined; - }); - - test('project-code retired phase is dropped from the denominator (Phase PROJ-42)', () => { - // Project-code dirs are not milestone-mapped for completion counts (a - // separate pre-existing limitation), so assert only the total_phases - // denominator, which #1514 governs: the struck PROJ-42 heading must not - // be counted, while PROJ-41 / PROJ-43 still are. - tmpDir = seedFromSpecs('bug-1514-pc-', [ - { id: 'PROJ-41', dir: 'PROJ-41-a', shipped: true }, - { id: 'PROJ-42', retired: true, dir: 'PROJ-42-d' }, - { id: 'PROJ-43', dir: 'PROJ-43-c', shipped: true }, - ]); - const result = runGsdTools(['state', 'json'], tmpDir); - assert.ok(result.success, `state json failed: ${result.error}`); - const { progress } = JSON.parse(result.output); - assert.equal(progress.total_phases, 2, `retired project-code phase must be excluded. Got ${progress.total_phases}`); - }); - - test('decimal, multiple, shipped-then-retired, and no-directory retired phases all excluded', () => { - // Retired: 02 (executed → has SUMMARY, then folded), 04 (no work), - // 05.1 (decimal, no directory at all). Live: 01, 03, 06. - tmpDir = seedFromSpecs('bug-1514-multi-', [ - { id: '01', dir: '01-a', shipped: true }, - { id: '02', retired: true, dir: '02-b', shipped: true }, - { id: '03', dir: '03-c', shipped: true }, - { id: '04', retired: true, dir: '04-d' }, - { id: '05.1', retired: true }, - { id: '06', dir: '06-f', shipped: true }, - ]); - const result = runGsdTools(['state', 'json'], tmpDir); - assert.ok(result.success, `state json failed: ${result.error}`); - const { progress } = JSON.parse(result.output); - assert.equal(progress.total_phases, 3, `3 retired of 6 → total 3. Got ${progress.total_phases}`); - assert.equal(progress.completed_phases, 3, `live phases 01/03/06 complete. Got ${progress.completed_phases}`); - assert.equal(progress.percent, 100, `Got ${progress.percent}`); - }); - - test('boundary: every phase retired (k === n) → total_phases 0', () => { - tmpDir = seedFromSpecs('bug-1514-all-', [ - { id: '01', retired: true, dir: '01-a' }, - { id: '02', retired: true, dir: '02-b' }, - { id: '03', retired: true, dir: '03-c' }, - ]); - const result = runGsdTools(['state', 'json'], tmpDir); - assert.ok(result.success, `state json failed: ${result.error}`); - const { progress } = JSON.parse(result.output); - assert.equal(progress.total_phases, 0, `all phases retired → denominator 0. Got ${progress.total_phases}`); - assert.equal(progress.completed_phases, 0, `Got ${progress.completed_phases}`); - }); - - test('strikethrough in a non-checklist/heading line (a goal) does NOT retire that phase', () => { - // Detection is scoped to checklist/heading lines, so a struck GOAL line - // that begins with a phase reference must not retire it. - const tmp = createTempProject('bug-1514-prose-'); - const planning = path.join(tmp, '.planning'); - const roadmapText = [ - '## Milestone v1.0: Prose', - '', - '### Phases', - '- [x] **Phase 01: A** — done', - '- [x] **Phase 02: B** — done', - '- [x] **Phase 03: C** — done', - '', - '### Phase 01: A', '**Goal:** g', - '### Phase 02: B', '**Goal:** ~~Phase 02 was renamed from an earlier plan~~', - '### Phase 03: C', '**Goal:** g', - ].join('\n'); - fs.writeFileSync(path.join(planning, 'ROADMAP.md'), roadmapText, 'utf-8'); - fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8'); - fs.writeFileSync( - path.join(planning, 'STATE.md'), - ['---', 'gsd_state_version: 1.0', 'milestone: v1.0', 'status: executing', '---', '', '# GSD State', '', '## Configuration', 'Current Phase: 3'].join('\n'), - 'utf-8', - ); - for (const d of ['01-a', '02-b', '03-c']) { - const dir = path.join(planning, 'phases', d); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(path.join(dir, 'PLAN.md'), '# Plan\n', 'utf-8'); - fs.writeFileSync(path.join(dir, 'SUMMARY.md'), '# Summary\n', 'utf-8'); - } - tmpDir = tmp; - const result = runGsdTools(['state', 'json'], tmpDir); - assert.ok(result.success, `state json failed: ${result.error}`); - const { progress } = JSON.parse(result.output); - assert.equal(progress.total_phases, 3, `struck prose in a goal line must not retire Phase 02. Got ${progress.total_phases}`); - assert.equal(progress.completed_phases, 3, `Got ${progress.completed_phases}`); - }); -}); - -// ─── Property: the strikethrough parser extracts exactly the struck set ────── - -// extractRetiredPhaseNumbers is the parsing/transformation core of the fix, so -// per RULESET.TESTS.property-based-testing it carries a fast-check property: -// for a roadmap with k of n checklist phases struck, the parser must return -// exactly the canonical keys of those k phases — no more, no fewer — across -// randomized phase counts and numeric/zero-padded/project-code ID forms. This -// underpins the `total_phases === n - k` guarantee the integration tests assert. -describe('bug #1514 — extractRetiredPhaseNumbers property: returns exactly the struck set', () => { - const idForm = (num, form) => - form === 'padded' ? String(num).padStart(2, '0') - : form === 'project' ? `PROJ-${num}` - : String(num); - const keyOf = (num, form) => normalizePhaseName(idForm(num, form)).toUpperCase(); - - test('k-of-n struck phases → exactly k canonical keys, for any n/form', () => { - fc.assert( - fc.property( - // Distinct phase numbers so canonical keys don't collide within a run. - fc.uniqueArray(fc.integer({ min: 1, max: 98 }), { minLength: 1, maxLength: 10 }), - fc.array(fc.boolean(), { minLength: 1, maxLength: 10 }), - fc.constantFrom('plain', 'padded', 'project'), - (nums, flagsRaw, form) => { - const lines = ['## Milestone v1.0: M', '', '### Phases']; - const struck = []; - nums.forEach((num, i) => { - const id = idForm(num, form); - if (flagsRaw[i]) { - lines.push(`- [x] ~~**Phase ${id}: P${num}**~~ — folded; retired`); - struck.push(num); - } else { - lines.push(`- [x] **Phase ${id}: P${num}** — done`); - } - }); - - const got = _extractRetiredPhaseNumbers(lines.join('\n')); - const expected = new Set(struck.map((num) => keyOf(num, form))); - - assert.equal(got.size, expected.size, `size: got ${got.size}, expected ${expected.size}`); - for (const k of expected) assert.ok(got.has(k), `missing struck key ${k}`); - for (const k of got) assert.ok(expected.has(k), `extra (non-struck) key ${k}`); - }, - ), - ); - }); -}); diff --git a/tests/fix-1515-codex-runtime-default.test.cjs b/tests/fix-1515-codex-runtime-default.test.cjs deleted file mode 100644 index 1af888539..000000000 --- a/tests/fix-1515-codex-runtime-default.test.cjs +++ /dev/null @@ -1,135 +0,0 @@ -'use strict'; -/** - * Regression tests for bug #1515: Codex install with runtime-neutral - * .planning/config.json resolves runtime as 'claude' and enables worktree - * isolation (unsafe for Codex). - * - * Root causes: - * A) config-get reads in workflows lacked --raw → output JSON-quoted → - * every comparison like [ "$RUNTIME" = "codex" ] failed silently. - * B) The conversion engine emitted --default claude for every runtime → - * neutral Codex config fell back to claude default. - * - * All tests assert on the SUT's RETURN VALUE (engine output), not raw file reads, - * except the integration test (test 4) which is explicitly the source↔engine - * parity guard and carries the allow-test-rule exemption. - */ - -const { test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const fc = require('fast-check'); -const conversion = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs'); - -// --------------------------------------------------------------------------- -// Unit tests: engine stamps codex-specific defaults into emitted workflows -// --------------------------------------------------------------------------- - -test('codex emit stamps its own runtime default into the runtime-resolution line', () => { - const line = - 'RUNTIME=$(gsd_run query config-get runtime --default claude --raw 2>/dev/null || echo "claude")\n'; - const out = conversion._applyRuntimeRewrites(line, 'codex', '$HOME/.codex/', true, undefined); - assert.ok( - out.includes('config-get runtime --default codex --raw'), - `Expected 'config-get runtime --default codex --raw' in output; got:\n${out}`, - ); - assert.ok( - out.includes('|| echo "codex")'), - `Expected '|| echo "codex")' in output; got:\n${out}`, - ); - assert.ok( - !out.includes('--default claude'), - `Expected '--default claude' to be fully rewritten; got:\n${out}`, - ); - assert.ok( - !out.includes('echo "claude"'), - `Expected 'echo "claude"' to be fully rewritten; got:\n${out}`, - ); -}); - -test('codex emit defaults workflow.use_worktrees to false', () => { - const line = - 'USE_WORKTREES=$(gsd_run query config-get workflow.use_worktrees --raw 2>/dev/null || echo "true")\n'; - const out = conversion._applyRuntimeRewrites(line, 'codex', '$HOME/.codex/', true, undefined); - assert.ok( - out.includes('config-get workflow.use_worktrees --default false --raw'), - `Expected 'config-get workflow.use_worktrees --default false --raw' in output; got:\n${out}`, - ); - assert.ok( - out.includes('|| echo "false")'), - `Expected '|| echo "false")' in output; got:\n${out}`, - ); - assert.ok( - !out.includes('|| echo "true")'), - `Expected '|| echo "true")' to be fully rewritten; got:\n${out}`, - ); -}); - -test('claude runtime does NOT rewrite the runtime default — stamping is non-claude-scoped (#1521 inversion)', () => { - // #1521 generalizes stamping to ALL non-Claude runtimes. The negative case - // (no stamping) is now the 'claude' runtime, not other non-Claude runtimes. - const line = - 'RUNTIME=$(gsd_run query config-get runtime --default claude --raw 2>/dev/null || echo "claude")\n'; - const out = conversion._applyRuntimeRewrites(line, 'claude', '$HOME/.claude/', true, undefined); - assert.ok( - out.includes('--default claude --raw'), - `Expected claude output to preserve '--default claude --raw'; got:\n${out}`, - ); - assert.ok( - !out.includes('--default codex'), - `Expected claude output NOT to contain '--default codex'; got:\n${out}`, - ); -}); - -// --------------------------------------------------------------------------- -// Integration / parity guard: real source ↔ engine output for codex (all surfaces) -// --------------------------------------------------------------------------- - -test('regression: every edited workflow gets codex-stamped (source↔engine parity, all surfaces) (#1515)', () => { - // allow-test-rule: emitted workflow runtime-resolution shell block is the runtime contract surface (#1515) — asserts on engine-transformed output of the real source - const WORKFLOWS = ['execute-phase.md', 'autonomous.md', 'manager.md', 'diagnose-issues.md', 'quick.md']; - const CLAUDE_RUNTIME = 'config-get runtime --default claude --raw 2>/dev/null || echo "claude"'; - const CODEX_RUNTIME = 'config-get runtime --default codex --raw 2>/dev/null || echo "codex"'; - const TRUE_WT = 'config-get workflow.use_worktrees --raw 2>/dev/null || echo "true"'; - const FALSE_WT = 'config-get workflow.use_worktrees --default false --raw 2>/dev/null || echo "false"'; - for (const wf of WORKFLOWS) { - const src = fs.readFileSync(path.join(__dirname, '..', 'gsd-core', 'workflows', wf), 'utf8'); - const out = conversion._applyRuntimeRewrites(src, 'codex', '$HOME/.codex/', true, undefined); - // No un-stamped claude/true resolution line may survive codex emit on ANY surface. - assert.ok(!out.includes(CLAUDE_RUNTIME), `${wf}: residual un-stamped runtime read — engine regex no longer matches source line (parity drift)`); - assert.ok(!out.includes(TRUE_WT), `${wf}: residual un-stamped use_worktrees read — parity drift`); - // If the source HAS such a read, the codex form must be present. - if (src.includes(CLAUDE_RUNTIME)) assert.ok(out.includes(CODEX_RUNTIME), `${wf}: runtime read not stamped to codex`); - if (src.includes(TRUE_WT)) assert.ok(out.includes(FALSE_WT), `${wf}: use_worktrees read not defaulted to false`); - } -}); - -// --------------------------------------------------------------------------- -// Property tests (RULESET.TESTS.property-based-testing) -// --------------------------------------------------------------------------- - -test('property: runtime stamping applies for ALL non-claude runtimes; only claude leaves --default claude unchanged (#1521)', () => { - // #1521: generalised from codex-only to all non-claude runtimes. - // Use the canonical list from the conversion module to avoid hand-rolled array drift. - const { NON_CLAUDE_RUNTIMES } = conversion; - const RUNTIMES = ['claude', ...NON_CLAUDE_RUNTIMES]; - const line = 'RUNTIME=$(gsd_run query config-get runtime --default claude --raw 2>/dev/null || echo "claude")\n'; - fc.assert(fc.property(fc.constantFrom(...RUNTIMES), (rt) => { - const out = conversion._applyRuntimeRewrites(line, rt, `$HOME/.${rt}/`, true, undefined); - return rt === 'claude' - ? out.includes('--default claude --raw') && !out.includes('--default codex') - : out.includes(`--default ${rt} --raw`) && !out.includes('--default claude'); - })); -}); - -test('property: codex stamping is idempotent on resolution lines (#1515)', () => { - fc.assert(fc.property(fc.constantFrom('runtime', 'use_worktrees'), (which) => { - const line = which === 'runtime' - ? 'RUNTIME=$(gsd_run query config-get runtime --default claude --raw 2>/dev/null || echo "claude")\n' - : 'USE_WORKTREES=$(gsd_run query config-get workflow.use_worktrees --raw 2>/dev/null || echo "true")\n'; - const once = conversion._applyRuntimeRewrites(line, 'codex', '$HOME/.codex/', true, undefined); - const twice = conversion._applyRuntimeRewrites(once, 'codex', '$HOME/.codex/', true, undefined); - return once === twice; - })); -}); diff --git a/tests/fix-1521-non-claude-runtime-default-resolution.test.cjs b/tests/fix-1521-non-claude-runtime-default-resolution.test.cjs deleted file mode 100644 index d31e94834..000000000 --- a/tests/fix-1521-non-claude-runtime-default-resolution.test.cjs +++ /dev/null @@ -1,235 +0,0 @@ -'use strict'; -/** - * Regression tests for #1521: every non-Claude runtime stamps its own runtime - * identity + workflow.use_worktrees=false into emitted workflows. - * - * GSD's worktree isolation relies on Claude Code's isolation="worktree" spawn - * parameter, which no other runtime honors. #1519 (Codex-only fix) is - * generalized here to ALL non-Claude runtimes. - * - * All tests assert on the SUT's RETURN VALUE (engine output), not raw file reads, - * except the parity integration test which carries the allow-test-rule exemption. - */ - -const { test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const fc = require('fast-check'); -const conversion = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs'); - -// #1521: use the canonical list from the conversion module rather than a hand-rolled -// local array that can drift from the real runtime set. -const { NON_CLAUDE_RUNTIMES: NON_CLAUDE } = conversion; -const WORKFLOWS = [ - 'execute-phase.md', 'autonomous.md', 'manager.md', 'diagnose-issues.md', 'quick.md', -]; - -const CLAUDE_RUNTIME_LINE = 'config-get runtime --default claude --raw 2>/dev/null || echo "claude"'; -const TRUE_WT_LINE = 'config-get workflow.use_worktrees --raw 2>/dev/null || echo "true"'; -const FALSE_WT_LINE = 'config-get workflow.use_worktrees --default false --raw 2>/dev/null || echo "false"'; - -// --------------------------------------------------------------------------- -// Parity across ALL non-Claude runtimes × all 5 workflows -// --------------------------------------------------------------------------- - -test('parity: every non-Claude runtime stamps its own runtime default and use_worktrees=false on all workflows (#1521)', () => { - // allow-test-rule: emitted workflow runtime-resolution shell block is the runtime contract surface (#1521) - for (const rt of NON_CLAUDE) { - for (const wf of WORKFLOWS) { - const src = fs.readFileSync( - path.join(__dirname, '..', 'gsd-core', 'workflows', wf), - 'utf8', - ); - const out = conversion._applyRuntimeRewrites(src, rt, `$HOME/.${rt}/`, true, undefined); - - // No un-stamped claude runtime line may survive - assert.ok( - !out.includes(CLAUDE_RUNTIME_LINE), - `${rt}/${wf}: residual un-stamped claude runtime read — _stampNonClaudeRuntimeDefaults not applied`, - ); - - // No un-stamped use_worktrees=true line may survive - assert.ok( - !out.includes(TRUE_WT_LINE), - `${rt}/${wf}: residual un-stamped use_worktrees=true read — _stampNonClaudeRuntimeDefaults not applied`, - ); - - // If the source had a runtime read, the output must have --default - if (src.includes(CLAUDE_RUNTIME_LINE)) { - assert.ok( - out.includes(`config-get runtime --default ${rt} --raw 2>/dev/null || echo "${rt}"`), - `${rt}/${wf}: runtime line not stamped to --default ${rt}`, - ); - } - - // If the source had a use_worktrees read, the output must have --default false - if (src.includes(TRUE_WT_LINE)) { - assert.ok( - out.includes(FALSE_WT_LINE), - `${rt}/${wf}: use_worktrees line not defaulted to false`, - ); - } - } - } -}); - -// --------------------------------------------------------------------------- -// Claude unchanged — no stamping for the native runtime -// --------------------------------------------------------------------------- - -test('claude runtime leaves runtime default and use_worktrees=true unchanged (#1521)', () => { - const src = fs.readFileSync( - path.join(__dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md'), - 'utf8', - ); - const out = conversion._applyRuntimeRewrites(src, 'claude', '$HOME/.claude/', true, undefined); - - // Claude emit must preserve the original --default claude line - if (src.includes(CLAUDE_RUNTIME_LINE)) { - assert.ok( - out.includes(CLAUDE_RUNTIME_LINE), - `claude/execute-phase.md: expected original claude runtime line to survive; got mutated`, - ); - } - - // Claude emit must NOT gain --default false for use_worktrees - assert.ok( - !out.includes(FALSE_WT_LINE), - `claude/execute-phase.md: use_worktrees line must NOT be stamped false for claude runtime`, - ); -}); - -// --------------------------------------------------------------------------- -// fc property — identity: each runtime stamps itself, claude stays unchanged -// --------------------------------------------------------------------------- - -test('property: _stampNonClaudeRuntimeDefaults stamps each non-claude runtime and leaves claude unchanged (#1521)', () => { - const line = - 'RUNTIME=$(gsd_run query config-get runtime --default claude --raw 2>/dev/null || echo "claude")\n'; - fc.assert( - fc.property(fc.constantFrom(...NON_CLAUDE, 'claude'), (rt) => { - const out = conversion._applyRuntimeRewrites(line, rt, `$HOME/.${rt}/`, true, undefined); - if (rt === 'claude') { - return out.includes('--default claude') && !/--default (?!claude)/.test(out); - } - return out.includes(`--default ${rt}`) && !out.includes('--default claude'); - }), - ); -}); - -// --------------------------------------------------------------------------- -// fc property — idempotence: stamping twice equals once -// --------------------------------------------------------------------------- - -test('property: _stampNonClaudeRuntimeDefaults is idempotent (#1521)', () => { - fc.assert( - fc.property( - fc.constantFrom(...NON_CLAUDE), - fc.constantFrom('runtime', 'use_worktrees'), - (rt, which) => { - const line = - which === 'runtime' - ? 'RUNTIME=$(gsd_run query config-get runtime --default claude --raw 2>/dev/null || echo "claude")\n' - : 'USE_WORKTREES=$(gsd_run query config-get workflow.use_worktrees --raw 2>/dev/null || echo "true")\n'; - const once = conversion._applyRuntimeRewrites(line, rt, `$HOME/.${rt}/`, true, undefined); - const twice = conversion._applyRuntimeRewrites(once, rt, `$HOME/.${rt}/`, true, undefined); - return once === twice; - }, - ), - ); -}); - -// --------------------------------------------------------------------------- -// Guard generalization: execute-phase.md uses != "claude" not = "codex" -// --------------------------------------------------------------------------- - -// --------------------------------------------------------------------------- -// Guard generalization: execute-phase.md, quick.md, and diagnose-issues.md -// all use != "claude" (not = "codex") for the worktree guard (#1521) -// --------------------------------------------------------------------------- - -test('execute-phase.md, quick.md, and diagnose-issues.md guards are generalized to != "claude" (not Codex-specific) (#1521)', () => { - // allow-test-rule: emitted workflow runtime-resolution shell block is the runtime contract surface (#1521) - const GUARD_WORKFLOWS = ['execute-phase.md', 'quick.md', 'diagnose-issues.md']; - for (const wf of GUARD_WORKFLOWS) { - const src = fs.readFileSync( - path.join(__dirname, '..', 'gsd-core', 'workflows', wf), - 'utf8', - ); - assert.ok( - src.includes('[ "$RUNTIME" != "claude" ] && [ "$USE_WORKTREES" != "false" ]'), - `${wf}: expected generalized guard [ "$RUNTIME" != "claude" ] && [ "$USE_WORKTREES" != "false" ]`, - ); - assert.ok( - !src.includes('[ "$RUNTIME" = "codex" ] && [ "$USE_WORKTREES" != "false" ]'), - `${wf}: found Codex-specific guard — should have been generalized to != "claude"`, - ); - } -}); - -// --------------------------------------------------------------------------- -// Orchestration gating: manager.md + autonomous.md gate background dispatch on -// the typed FLATTEN query. #1708 (ADR-1239 Phase B) graduated #1521's -// codex-specific check to a documentation-sourced shouldFlattenDispatch — the -// prose now branches on `FLATTEN` (false = background), not a runtime name. -// --------------------------------------------------------------------------- - -test('manager.md and autonomous.md gate run_in_background on FLATTEN=false, not a runtime name (#1521, graduated by #1708)', () => { - // allow-test-rule: orchestration dispatch gating in manager/autonomous .md is the runtime contract surface (#1521/#1708) - const manager = fs.readFileSync( - path.join(__dirname, '..', 'gsd-core', 'workflows', 'manager.md'), - 'utf8', - ); - const autonomous = fs.readFileSync( - path.join(__dirname, '..', 'gsd-core', 'workflows', 'autonomous.md'), - 'utf8', - ); - - // Both files must gate run_in_background on the typed FLATTEN decision (not a runtime name) - assert.ok( - /If `FLATTEN` is `false`[\s\S]{0,500}?run_in_background=true/.test(manager), - 'manager.md: expected run_in_background dispatch gated on FLATTEN=false (typed dispatch-should-flatten query)', - ); - assert.ok( - /If `FLATTEN` is `false`[\s\S]{0,1200}?run_in_background=true/.test(autonomous), - 'autonomous.md: expected run_in_background dispatch gated on FLATTEN=false (typed dispatch-should-flatten query)', - ); - - // Inline is the else branch, keyed on FLATTEN — never a runtime name - assert.ok( - /Otherwise[\s\S]{0,250}?inline/i.test(manager), - 'manager.md: expected "Otherwise ... inline" branch keyed on FLATTEN', - ); - assert.ok( - /Otherwise[\s\S]{0,250}?inline/i.test(autonomous), - 'autonomous.md: expected "Otherwise ... inline" branch keyed on FLATTEN', - ); - // And the old runtime-name gating must be gone (no `RUNTIME` is `codex` dispatch gate) - assert.ok( - !/`RUNTIME` is `codex`[\s\S]{0,500}?run_in_background=true/.test(manager), - 'manager.md: must no longer gate run_in_background on the runtime name', - ); -}); - -test('manager.md and autonomous.md no longer contain old "not claude" background-dispatch gating (#1521)', () => { - // allow-test-rule: orchestration dispatch gating in manager/autonomous .md is the runtime contract surface (#1521) - const manager = fs.readFileSync( - path.join(__dirname, '..', 'gsd-core', 'workflows', 'manager.md'), - 'utf8', - ); - const autonomous = fs.readFileSync( - path.join(__dirname, '..', 'gsd-core', 'workflows', 'autonomous.md'), - 'utf8', - ); - - // The old phrasing that unconditionally sent every non-claude runtime to background must be gone - assert.ok( - !manager.includes('If `RUNTIME` is not `claude` (e.g. Codex)'), - 'manager.md: old "If `RUNTIME` is not `claude` (e.g. Codex)" gating must be replaced', - ); - assert.ok( - !autonomous.includes('On other runtimes:'), - 'autonomous.md: old "On other runtimes:" branch label must be replaced', - ); -}); diff --git a/tests/fix-1628-config-set-validation.test.cjs b/tests/fix-1628-config-set-validation.test.cjs deleted file mode 100644 index 0493bc567..000000000 --- a/tests/fix-1628-config-set-validation.test.cjs +++ /dev/null @@ -1,614 +0,0 @@ -'use strict'; - -/** - * Regression test suite for bug #1628: config-set validation gaps. - * - * This file consolidates all #1628 config-set validation regression tests: - * 1. Security-key enum guards (workflow.security_block_on, workflow.security_asvs_level) - * 2. JSON-array coercion bypass: every affected string-enum key - * 3. Generic capability-registry validation (enum/boolean/number/string keys) - * - * Covers: - * - workflow.security_block_on must be one of: critical | high | medium | low | none - * - workflow.security_asvs_level must be an integer in {1, 2, 3} - * - JSON-array ([""]) and JSON-object ({"x":1}) values must be REJECTED for - * all string-enum keys (typeof check before enum guard) - * - capability-registry-owned keys: ENUM, BOOLEAN, NUMBER, STRING - * - * Boundary coverage per RULESET.TESTS.boundary-coverage: - * security_asvs_level: 0 (limit-1), 1 (limit), 2, 3 (limit), 4 (limit+1) - * security_block_on: each valid enum member + bogus values - * - * Registry canary: verifies capability registry's .values for workflow.security_block_on - * matches the canonical enum (guards against silent gutting per DEFECT.GENERATIVE-FIX). - */ - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs'); - -// ─── Registry canary ────────────────────────────────────────────────────────── -// Verify the capability registry's declared .values for workflow.security_block_on -// matches the canonical enum. config.cts sources its allowed set DIRECTLY from the -// registry, so this canary guards against the registry being silently gutted — which -// would cause every config-set call to fail (per DEFECT.GENERATIVE-FIX). -describe('fix-1628: registry canary — capability registry declares the canonical security_block_on enum', () => { - test('registry workflow.security_block_on.values declares the expected canonical enum', () => { - // Load the capability registry as a module (behavioral call, not source grep). - // The registry IS the source of truth: config.cts reads from it at runtime. - // This assertion guards against the registry entry being gutted or values removed. - const { configSchema } = require('../gsd-core/bin/lib/capability-registry.cjs'); - const entry = configSchema['workflow.security_block_on']; - assert.ok(entry, 'capability registry must have an entry for workflow.security_block_on'); - assert.ok(Array.isArray(entry.values), 'registry entry must have a .values array'); - - const EXPECTED = ['critical', 'high', 'medium', 'low', 'none']; - assert.deepEqual( - [...entry.values].sort(), - [...EXPECTED].sort(), - `Registry workflow.security_block_on.values must be ${JSON.stringify(EXPECTED)} — update ` + - `the capability registry if the canonical enum changes` - ); - }); -}); - -// ─── workflow.security_block_on ─────────────────────────────────────────────── - -describe('fix-1628: workflow.security_block_on enum validation', () => { - const VALID_VALUES = ['critical', 'high', 'medium', 'low', 'none']; - const INVALID_VALUES = ['bogus', 'High', 'CRITICAL', '', 'all', 'urgent']; - - for (const v of VALID_VALUES) { - test(`config-set workflow.security_block_on=${v} is ACCEPTED`, (t) => { - const tmpDir = createTempProject(); - t.after(() => cleanup(tmpDir)); - const result = runGsdTools( - ['config-set', 'workflow.security_block_on', v], - tmpDir - ); - assert.ok( - result.success, - [ - `config-set workflow.security_block_on=${v} must succeed,`, - 'stdout: ' + result.output, - 'stderr: ' + result.error, - ].join('\n') - ); - }); - } - - for (const v of INVALID_VALUES) { - test(`config-set workflow.security_block_on=${JSON.stringify(v)} is REJECTED`, (t) => { - const tmpDir = createTempProject(); - t.after(() => cleanup(tmpDir)); - const result = runGsdTools( - ['config-set', 'workflow.security_block_on', v], - tmpDir - ); - assert.ok( - !result.success, - `config-set workflow.security_block_on=${JSON.stringify(v)} must fail, but it succeeded` - ); - const combined = (result.output || '') + (result.error || ''); - // Error message must mention the valid values - assert.ok( - combined.includes('critical') && combined.includes('none'), - `Error message must mention valid values (got: ${combined})` - ); - }); - } -}); - -// ─── workflow.security_block_on — JSON-parse coercion bypass ───────────────── -// Regression for the String(parsedValue) coercion bug: an array like ["high"] -// coerces to "high" via String(), bypassing the enum check and writing an array -// to a string-enum key. The fix requires typeof parsedValue === 'string'. - -describe('fix-1628: workflow.security_block_on rejects JSON-parsed non-string inputs', () => { - const JSON_BYPASS_CASES = [ - { val: '["high"]', label: 'JSON array with valid member' }, - { val: '["bogus"]', label: 'JSON array with invalid member' }, - { val: '{"high":1}', label: 'JSON object' }, - ]; - - for (const { val, label } of JSON_BYPASS_CASES) { - test(`config-set workflow.security_block_on=${label} is REJECTED`, (t) => { - const tmpDir = createTempProject(); - t.after(() => cleanup(tmpDir)); - const result = runGsdTools( - ['config-set', 'workflow.security_block_on', val], - tmpDir - ); - assert.ok( - !result.success, - `config-set workflow.security_block_on=${label} must fail, but it succeeded` - ); - }); - } -}); - -// ─── workflow.security_asvs_level ───────────────────────────────────────────── - -describe('fix-1628: workflow.security_asvs_level range validation', () => { - // Boundary: 0 (below limit), 1 (min valid), 2 (mid), 3 (max valid), 4 (above limit) - const ACCEPTED_INTEGERS = [1, 2, 3]; - const REJECTED_VALUES = [ - { val: '0', label: '0 (below lower bound)' }, - { val: '4', label: '4 (above upper bound)' }, - { val: '2.5', label: '2.5 (non-integer float)' }, - { val: 'abc', label: '"abc" (non-numeric string)' }, - { val: '-1', label: '-1 (negative)' }, - ]; - - for (const n of ACCEPTED_INTEGERS) { - test(`config-set workflow.security_asvs_level=${n} is ACCEPTED`, (t) => { - const tmpDir = createTempProject(); - t.after(() => cleanup(tmpDir)); - const result = runGsdTools( - ['config-set', 'workflow.security_asvs_level', String(n)], - tmpDir - ); - assert.ok( - result.success, - [ - `config-set workflow.security_asvs_level=${n} must succeed,`, - 'stdout: ' + result.output, - 'stderr: ' + result.error, - ].join('\n') - ); - }); - } - - for (const { val, label } of REJECTED_VALUES) { - test(`config-set workflow.security_asvs_level=${label} is REJECTED`, (t) => { - const tmpDir = createTempProject(); - t.after(() => cleanup(tmpDir)); - const result = runGsdTools( - ['config-set', 'workflow.security_asvs_level', val], - tmpDir - ); - assert.ok( - !result.success, - `config-set workflow.security_asvs_level=${label} must fail, but it succeeded` - ); - const combined = (result.output || '') + (result.error || ''); - assert.ok( - combined.includes('security_asvs_level'), - `Error message must reference the key name (got: ${combined})` - ); - }); - } -}); - -// ─── JSON-array coercion bypass — parameterised matrix ─────────────────────── -// The root cause: cmdConfigSet JSON-parses any value starting with '[' or '{' -// BEFORE per-key enum guards run. Guards using `.includes(String(parsedValue))` -// are then fooled because `String(["mid-flight"]) === "mid-flight"`, so the -// array bypasses the guard and gets stored in a scalar key. -// -// The fix: `assertEnumValue()` checks `typeof parsedValue === 'string'` FIRST, -// so a parsed array is rejected regardless of its string coercion. -// -// Coverage: every affected string-enum key. -// - `[""]` (JSON array with valid member) → REJECTED -// - `{"x":1}` (JSON object) → REJECTED -// - `` (plain string, valid) → ACCEPTED - -// Each row: { key, member } where `member` is a valid enum value for `key`. -// Verified against VALID_* arrays in src/config.cts. -const ENUM_KEYS = [ - { key: 'context', member: 'research' }, - { key: 'workflow.drift_action', member: 'warn' }, - { key: 'workflow.human_verify_mode', member: 'mid-flight' }, - { key: 'workflow.context_guard_mode', member: 'off' }, - { key: 'statusline.context_position', member: 'front' }, - { key: 'code_quality.fallow.scope', member: 'phase' }, - { key: 'code_quality.fallow.profile', member: 'standard' }, - { key: 'plan_review.source_grounding_authority', member: 'grep' }, - { key: 'workflow.security_block_on', member: 'high' }, -]; - -for (const { key, member } of ENUM_KEYS) { - describe(`fix-1628 coercion bypass: ${key}`, () => { - // ── JSON array with valid member must be REJECTED ──────────────────────── - test(`["${member}"] (JSON array with valid member) is REJECTED`, (t) => { - const tmpDir = createTempProject(); - t.after(() => cleanup(tmpDir)); - const val = `["${member}"]`; - const result = runGsdTools(['config-set', key, val], tmpDir); - assert.ok( - !result.success, - [ - `config-set ${key}=${val} must be REJECTED (JSON-array coercion bypass)`, - 'stdout: ' + result.output, - 'stderr: ' + result.error, - ].join('\n') - ); - }); - - // ── JSON object must be REJECTED ───────────────────────────────────────── - test(`{"x":1} (JSON object) is REJECTED`, (t) => { - const tmpDir = createTempProject(); - t.after(() => cleanup(tmpDir)); - const val = '{"x":1}'; - const result = runGsdTools(['config-set', key, val], tmpDir); - assert.ok( - !result.success, - [ - `config-set ${key}=${val} must be REJECTED (JSON-object bypass)`, - 'stdout: ' + result.output, - 'stderr: ' + result.error, - ].join('\n') - ); - }); - - // ── Plain valid string must be ACCEPTED ────────────────────────────────── - test(`"${member}" (plain valid string) is ACCEPTED`, (t) => { - const tmpDir = createTempProject(); - t.after(() => cleanup(tmpDir)); - const result = runGsdTools(['config-set', key, member], tmpDir); - assert.ok( - result.success, - [ - `config-set ${key}=${member} must be ACCEPTED (plain string, valid enum member)`, - 'stdout: ' + result.output, - 'stderr: ' + result.error, - ].join('\n') - ); - }); - }); -} - -// ─── ENUM: workflow.code_review_depth ──────────────────────────────────────── - -describe('fix-1628 capability validation: workflow.code_review_depth (enum)', () => { - const VALID_VALUES = ['quick', 'standard', 'deep']; - - for (const v of VALID_VALUES) { - test(`config-set workflow.code_review_depth=${v} is ACCEPTED`, (t) => { - const tmpDir = createTempProject(); - t.after(() => cleanup(tmpDir)); - const result = runGsdTools(['config-set', 'workflow.code_review_depth', v], tmpDir); - assert.ok( - result.success, - [ - `config-set workflow.code_review_depth=${v} must succeed`, - 'stdout: ' + result.output, - 'stderr: ' + result.error, - ].join('\n') - ); - }); - } - - test('config-set workflow.code_review_depth=["standard"] (JSON array bypass) is REJECTED', (t) => { - const tmpDir = createTempProject(); - t.after(() => cleanup(tmpDir)); - const result = runGsdTools(['config-set', 'workflow.code_review_depth', '["standard"]'], tmpDir); - assert.ok( - !result.success, - [ - 'config-set workflow.code_review_depth=["standard"] must be REJECTED (JSON-array coercion bypass)', - 'stdout: ' + result.output, - 'stderr: ' + result.error, - ].join('\n') - ); - }); - - test('config-set workflow.code_review_depth=garbage is REJECTED', (t) => { - const tmpDir = createTempProject(); - t.after(() => cleanup(tmpDir)); - const result = runGsdTools(['config-set', 'workflow.code_review_depth', 'garbage'], tmpDir); - assert.ok( - !result.success, - [ - 'config-set workflow.code_review_depth=garbage must be REJECTED (out-of-enum)', - 'stdout: ' + result.output, - 'stderr: ' + result.error, - ].join('\n') - ); - }); -}); - -// ─── ENUM: mempalace.memory_mode ───────────────────────────────────────────── - -describe('fix-1628 capability validation: mempalace.memory_mode (enum)', () => { - const VALID_VALUES = ['augment', 'kg_backend', 'replace']; - - for (const v of VALID_VALUES) { - test(`config-set mempalace.memory_mode=${v} is ACCEPTED`, (t) => { - const tmpDir = createTempProject(); - t.after(() => cleanup(tmpDir)); - const result = runGsdTools(['config-set', 'mempalace.memory_mode', v], tmpDir); - assert.ok( - result.success, - [ - `config-set mempalace.memory_mode=${v} must succeed`, - 'stdout: ' + result.output, - 'stderr: ' + result.error, - ].join('\n') - ); - }); - } - - test('config-set mempalace.memory_mode=["augment"] (JSON array bypass) is REJECTED', (t) => { - const tmpDir = createTempProject(); - t.after(() => cleanup(tmpDir)); - const result = runGsdTools(['config-set', 'mempalace.memory_mode', '["augment"]'], tmpDir); - assert.ok( - !result.success, - [ - 'config-set mempalace.memory_mode=["augment"] must be REJECTED (JSON-array coercion bypass)', - 'stdout: ' + result.output, - 'stderr: ' + result.error, - ].join('\n') - ); - }); - - test('config-set mempalace.memory_mode=garbage is REJECTED', (t) => { - const tmpDir = createTempProject(); - t.after(() => cleanup(tmpDir)); - const result = runGsdTools(['config-set', 'mempalace.memory_mode', 'garbage'], tmpDir); - assert.ok( - !result.success, - [ - 'config-set mempalace.memory_mode=garbage must be REJECTED (out-of-enum)', - 'stdout: ' + result.output, - 'stderr: ' + result.error, - ].join('\n') - ); - }); -}); - -// ─── BOOLEAN: workflow.tdd_mode ────────────────────────────────────────────── - -describe('fix-1628 capability validation: workflow.tdd_mode (boolean)', () => { - test('config-set workflow.tdd_mode=true is ACCEPTED', (t) => { - const tmpDir = createTempProject(); - t.after(() => cleanup(tmpDir)); - const result = runGsdTools(['config-set', 'workflow.tdd_mode', 'true'], tmpDir); - assert.ok( - result.success, - [ - 'config-set workflow.tdd_mode=true must succeed', - 'stdout: ' + result.output, - 'stderr: ' + result.error, - ].join('\n') - ); - }); - - test('config-set workflow.tdd_mode=false is ACCEPTED', (t) => { - const tmpDir = createTempProject(); - t.after(() => cleanup(tmpDir)); - const result = runGsdTools(['config-set', 'workflow.tdd_mode', 'false'], tmpDir); - assert.ok( - result.success, - [ - 'config-set workflow.tdd_mode=false must succeed', - 'stdout: ' + result.output, - 'stderr: ' + result.error, - ].join('\n') - ); - }); - - test('config-set workflow.tdd_mode=["true"] (JSON array bypass) is REJECTED', (t) => { - const tmpDir = createTempProject(); - t.after(() => cleanup(tmpDir)); - const result = runGsdTools(['config-set', 'workflow.tdd_mode', '["true"]'], tmpDir); - assert.ok( - !result.success, - [ - 'config-set workflow.tdd_mode=["true"] must be REJECTED (JSON-array coercion bypass)', - 'stdout: ' + result.output, - 'stderr: ' + result.error, - ].join('\n') - ); - }); - - test('config-set workflow.tdd_mode={"x":1} (JSON object) is REJECTED', (t) => { - const tmpDir = createTempProject(); - t.after(() => cleanup(tmpDir)); - const result = runGsdTools(['config-set', 'workflow.tdd_mode', '{"x":1}'], tmpDir); - assert.ok( - !result.success, - [ - 'config-set workflow.tdd_mode={"x":1} must be REJECTED (JSON-object bypass)', - 'stdout: ' + result.output, - 'stderr: ' + result.error, - ].join('\n') - ); - }); - - test('config-set workflow.tdd_mode=maybe (non-boolean string) is REJECTED', (t) => { - const tmpDir = createTempProject(); - t.after(() => cleanup(tmpDir)); - const result = runGsdTools(['config-set', 'workflow.tdd_mode', 'maybe'], tmpDir); - assert.ok( - !result.success, - [ - 'config-set workflow.tdd_mode=maybe must be REJECTED (non-boolean string)', - 'stdout: ' + result.output, - 'stderr: ' + result.error, - ].join('\n') - ); - }); - - test('config-set workflow.tdd_mode=1 (numeric 1 coerces to number, not boolean) is REJECTED', (t) => { - const tmpDir = createTempProject(); - t.after(() => cleanup(tmpDir)); - const result = runGsdTools(['config-set', 'workflow.tdd_mode', '1'], tmpDir); - assert.ok( - !result.success, - [ - 'config-set workflow.tdd_mode=1 must be REJECTED (number, not boolean)', - 'stdout: ' + result.output, - 'stderr: ' + result.error, - ].join('\n') - ); - }); -}); - -// ─── BOOLEAN: graphify.enabled ──────────────────────────────────────────────── - -describe('fix-1628 capability validation: graphify.enabled (boolean)', () => { - test('config-set graphify.enabled=true is ACCEPTED', (t) => { - const tmpDir = createTempProject(); - t.after(() => cleanup(tmpDir)); - const result = runGsdTools(['config-set', 'graphify.enabled', 'true'], tmpDir); - assert.ok( - result.success, - [ - 'config-set graphify.enabled=true must succeed', - 'stdout: ' + result.output, - 'stderr: ' + result.error, - ].join('\n') - ); - }); - - test('config-set graphify.enabled=false is ACCEPTED', (t) => { - const tmpDir = createTempProject(); - t.after(() => cleanup(tmpDir)); - const result = runGsdTools(['config-set', 'graphify.enabled', 'false'], tmpDir); - assert.ok( - result.success, - [ - 'config-set graphify.enabled=false must succeed', - 'stdout: ' + result.output, - 'stderr: ' + result.error, - ].join('\n') - ); - }); - - test('config-set graphify.enabled=["true"] (JSON array bypass) is REJECTED', (t) => { - const tmpDir = createTempProject(); - t.after(() => cleanup(tmpDir)); - const result = runGsdTools(['config-set', 'graphify.enabled', '["true"]'], tmpDir); - assert.ok( - !result.success, - [ - 'config-set graphify.enabled=["true"] must be REJECTED (JSON-array coercion bypass)', - 'stdout: ' + result.output, - 'stderr: ' + result.error, - ].join('\n') - ); - }); - - test('config-set graphify.enabled={"x":1} (JSON object) is REJECTED', (t) => { - const tmpDir = createTempProject(); - t.after(() => cleanup(tmpDir)); - const result = runGsdTools(['config-set', 'graphify.enabled', '{"x":1}'], tmpDir); - assert.ok( - !result.success, - [ - 'config-set graphify.enabled={"x":1} must be REJECTED (JSON-object bypass)', - 'stdout: ' + result.output, - 'stderr: ' + result.error, - ].join('\n') - ); - }); - - test('config-set graphify.enabled=maybe (non-boolean string) is REJECTED', (t) => { - const tmpDir = createTempProject(); - t.after(() => cleanup(tmpDir)); - const result = runGsdTools(['config-set', 'graphify.enabled', 'maybe'], tmpDir); - assert.ok( - !result.success, - [ - 'config-set graphify.enabled=maybe must be REJECTED (non-boolean string)', - 'stdout: ' + result.output, - 'stderr: ' + result.error, - ].join('\n') - ); - }); - - test('config-set graphify.enabled=1 (numeric 1, not boolean) is REJECTED', (t) => { - const tmpDir = createTempProject(); - t.after(() => cleanup(tmpDir)); - const result = runGsdTools(['config-set', 'graphify.enabled', '1'], tmpDir); - assert.ok( - !result.success, - [ - 'config-set graphify.enabled=1 must be REJECTED (number, not boolean)', - 'stdout: ' + result.output, - 'stderr: ' + result.error, - ].join('\n') - ); - }); -}); - -// ─── NUMBER: workflow.drift_threshold ──────────────────────────────────────── - -describe('fix-1628 capability validation: workflow.drift_threshold (number)', () => { - test('config-set workflow.drift_threshold=5 is ACCEPTED', (t) => { - const tmpDir = createTempProject(); - t.after(() => cleanup(tmpDir)); - const result = runGsdTools(['config-set', 'workflow.drift_threshold', '5'], tmpDir); - assert.ok( - result.success, - [ - 'config-set workflow.drift_threshold=5 must succeed', - 'stdout: ' + result.output, - 'stderr: ' + result.error, - ].join('\n') - ); - }); - - test('config-set workflow.drift_threshold=["3"] (JSON array bypass) is REJECTED', (t) => { - const tmpDir = createTempProject(); - t.after(() => cleanup(tmpDir)); - const result = runGsdTools(['config-set', 'workflow.drift_threshold', '["3"]'], tmpDir); - assert.ok( - !result.success, - [ - 'config-set workflow.drift_threshold=["3"] must be REJECTED (JSON-array coercion bypass)', - 'stdout: ' + result.output, - 'stderr: ' + result.error, - ].join('\n') - ); - }); -}); - -// ─── STRING: mempalace.wing ─────────────────────────────────────────────────── - -describe('fix-1628 capability validation: mempalace.wing (string)', () => { - test('config-set mempalace.wing=myWing is ACCEPTED', (t) => { - const tmpDir = createTempProject(); - t.after(() => cleanup(tmpDir)); - const result = runGsdTools(['config-set', 'mempalace.wing', 'myWing'], tmpDir); - assert.ok( - result.success, - [ - 'config-set mempalace.wing=myWing must succeed', - 'stdout: ' + result.output, - 'stderr: ' + result.error, - ].join('\n') - ); - }); - - test('config-set mempalace.wing=["x"] (JSON array bypass) is REJECTED', (t) => { - const tmpDir = createTempProject(); - t.after(() => cleanup(tmpDir)); - const result = runGsdTools(['config-set', 'mempalace.wing', '["x"]'], tmpDir); - assert.ok( - !result.success, - [ - 'config-set mempalace.wing=["x"] must be REJECTED (JSON-array coercion bypass)', - 'stdout: ' + result.output, - 'stderr: ' + result.error, - ].join('\n') - ); - }); - - test('config-set mempalace.wing={"a":1} (JSON object) is REJECTED', (t) => { - const tmpDir = createTempProject(); - t.after(() => cleanup(tmpDir)); - const result = runGsdTools(['config-set', 'mempalace.wing', '{"a":1}'], tmpDir); - assert.ok( - !result.success, - [ - 'config-set mempalace.wing={"a":1} must be REJECTED (JSON-object bypass)', - 'stdout: ' + result.output, - 'stderr: ' + result.error, - ].join('\n') - ); - }); -}); diff --git a/tests/frontmatter.test.cjs b/tests/frontmatter.test.cjs index 8e6ff2d17..8a770765d 100644 --- a/tests/frontmatter.test.cjs +++ b/tests/frontmatter.test.cjs @@ -604,3 +604,1128 @@ must_haves: }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-1695-state-patch-clobbers-phase-name.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-1695-state-patch-clobbers-phase-name (consolidation epic #1969 B3 #1972)", () => { +'use strict'; +// Regression test for issue #1695 — `state patch` of an unrelated field clobbers +// the curated `current_phase_name` frontmatter scalar. +// +// Root cause: readModifyWriteStateMd({resync:false}) still runs syncStateFrontmatter, +// which re-derives EVERY body-derived scalar from body prose. The #1264 restore +// covers `progress` only and #1230 covers `status`/`stopped_at`; `current_phase_name` +// was left exposed, and parseProsePhaseField's paren-over-dash preference made the +// re-derived value wrong (harvesting a parenthetical aside as the phase name). +// +// ADR-1769 Phase 6 fix: extend the #1230 delta heuristic to current_phase_name +// (gated by the field-classification table's preserve-always row). When the +// transform did NOT change the body Current Phase / Phase source line, the curated +// frontmatter value wins. + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); +const { extractFrontmatter } = require('../gsd-core/bin/lib/frontmatter.cjs'); + +function buildStateWithCuratedPhaseName({ phaseName = 'Native Global Hotkey', aside = 'next; Phase 15 landed, UAT deferred' } = {}) { + return [ + '---', + 'gsd_state_version: 1.0', + 'milestone: v1.0', + 'milestone_name: Test', + 'current_phase: "16"', + `current_phase_name: "${phaseName}"`, + 'status: executing', + 'progress:', + ' total_phases: 20', + ' completed_phases: 15', + ' total_plans: 40', + ' completed_plans: 30', + ' percent: 75', + '---', + '', + '# GSD State', + '', + '## Configuration', + 'Current Phase: 16', + 'Total Plans in Phase: 4', + 'Current Plan: 2', + 'Status: Executing Phase 16', + 'Last Activity: 2026-06-20', + '', + '## Current Position', + '', + `Phase: 16 — ${phaseName} (${aside})`, + 'Plan: 2 of 4', + 'Status: Executing Phase 16', + 'Last activity: 2026-06-20 — mid-flight', + '', + ].join('\n'); +} + +function readFm(statePath) { + return extractFrontmatter(fs.readFileSync(statePath, 'utf-8')); +} + +describe('#1695: state patch of an unrelated field preserves curated current_phase_name', () => { + let tmpDir; + beforeEach(() => { tmpDir = createTempProject(); }); + afterEach(() => { cleanup(tmpDir); }); + + test('patching Status does NOT clobber the curated current_phase_name', () => { + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + fs.writeFileSync(statePath, buildStateWithCuratedPhaseName()); + + const result = runGsdTools(['query', 'state.patch', JSON.stringify({ Status: 'Paused for review' })], tmpDir); + assert.ok(result.success, `state patch failed: ${result.error}`); + + const fm = readFm(statePath); + assert.strictEqual( + fm.current_phase_name, + 'Native Global Hotkey', + `current_phase_name must be preserved on an unrelated patch; got ${JSON.stringify(fm.current_phase_name)} (the paren-over-dash re-derivation clobbered it — #1695)`, + ); + }); + + test('patching Current Plan does NOT clobber the curated current_phase_name', () => { + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + fs.writeFileSync(statePath, buildStateWithCuratedPhaseName()); + + const result = runGsdTools(['query', 'state.patch', JSON.stringify({ 'Current Plan': '3' })], tmpDir); + assert.ok(result.success, `state patch failed: ${result.error}`); + + const fm = readFm(statePath); + assert.strictEqual(fm.current_phase_name, 'Native Global Hotkey'); + }); + + test('explicitly patching the body Phase name-source line still advances (delta does not over-pin)', () => { + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + fs.writeFileSync(statePath, buildStateWithCuratedPhaseName()); + + // Patching the body 'Phase' field (the parseProsePhaseField source for + // current_phase_name) changes the source line, so the #1230 delta must NOT + // fire — syncStateFrontmatter re-derives current_phase_name from the new line. + // (Acceptance criterion from #1743: the guard must not pin a scalar whose body + // source genuinely changed.) + const result = runGsdTools(['query', 'state.patch', JSON.stringify({ Phase: '17 — Brand New Phase Name' })], tmpDir); + assert.ok(result.success, `state patch failed: ${result.error}`); + + const fm = readFm(statePath); + // current_phase_name should be re-derived from the new body 'Phase' line + // (not pinned to the old curated value). + assert.notStrictEqual(fm.current_phase_name, 'Native Global Hotkey', + `current_phase_name must advance when the body Phase source changed; got ${JSON.stringify(fm.current_phase_name)}`); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2770-annotate-deps-int-coerce.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2770-annotate-deps-int-coerce (consolidation epic #1969 B3 #1972)", () => { +'use strict'; + +// allow-test-rule: source-text-is-the-product (see #2770) +// Reads .md/.json/.yml product files whose deployed text IS what the +// runtime loads — testing text content tests the deployed contract. + +/** + * Regression — issue #2770 + * + * `roadmap.annotate-dependencies` crashes with + * `TypeError: t.trim is not a function` when must_haves.truths contains a + * non-string scalar (e.g., a YAML int like `- 3` interpreted by an upstream + * parser as a number, or a kv-shaped item whose value is numeric). + * + * The original guard `if (typeof t !== 'string') continue` skipped silently — + * which avoids the crash but **drops the constraint from cross-cutting + * analysis**. The required behaviour is to **coerce, not skip**: a numeric + * scalar `3` must be surfaced as the string "3", and a kv-shaped truth like + * `{ title: "X", count: 3 }` must contribute its title to the analysis. + * + * The two literal cases called out in the issue title (bare-int `depends_on` + * values) are also exercised here as regression guards on the frontmatter + * parser to prove the dependency is preserved as a string and never dropped. + */ + +const { test, describe, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); +const { extractFrontmatter } = require('../gsd-core/bin/lib/frontmatter.cjs'); + +function makePlanProject(files = {}) { + const dir = createTempProject(); + fs.writeFileSync(path.join(dir, '.planning', 'ROADMAP.md'), ''); + fs.mkdirSync(path.join(dir, '.planning', 'phases', '01-foundation'), { recursive: true }); + for (const [rel, content] of Object.entries(files)) { + const abs = path.join(dir, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, content, 'utf-8'); + } + return dir; +} + +const ROADMAP = [ + '# Roadmap', + '', + '### Phase 1: Foundation', + '**Goal:** Set up project', + '**Plans:** 2 plans', + '', + 'Plans:', + '- [ ] 01-01-PLAN.md — Set up DB', + '- [ ] 01-02-PLAN.md — Build API', + '', +].join('\n'); + +// PLAN where must_haves.truths includes a bare numeric scalar AND a kv-shaped +// item whose value is numeric — both must be surfaced as cross-cutting +// constraints when shared across plans, not silently dropped. +const PLAN_NUMERIC_TRUTH = (wave, sharedTitle) => [ + '---', + 'phase: "1"', + `plan: "01-0${wave}"`, + 'type: standard', + `wave: ${wave}`, + 'depends_on: []', + 'files_modified: []', + 'autonomous: true', + 'must_haves:', + ' truths:', + ` - title: ${sharedTitle}`, + ' count: 3', + ' - 42', + ' artifacts: []', + ' key_links: []', + '---', + '', + `Plan ${wave}`, + '', +].join('\n'); + +describe('bug #2770 — non-string truths must be coerced, not dropped', () => { + let tmpDir; + afterEach(() => cleanup(tmpDir)); + + test('numeric scalar truth shared across 2+ plans is surfaced as cross-cutting constraint', () => { + // Both plans share the numeric truth `42`. Pre-fix: silently dropped by + // `typeof t !== 'string' continue`, so cross_cutting_constraints == 0. + // Post-fix: coerced to "42" and surfaced as a constraint. + const PLAN_BARE_INT_TRUTH = (wave) => [ + '---', + 'phase: "1"', + `plan: "01-0${wave}"`, + 'type: standard', + `wave: ${wave}`, + 'depends_on: []', + 'files_modified: []', + 'autonomous: true', + 'must_haves:', + ' truths:', + ' - 42', + ' artifacts: []', + ' key_links: []', + '---', + '', + `Plan ${wave}`, + '', + ].join('\n'); + tmpDir = makePlanProject({ + '.planning/ROADMAP.md': ROADMAP, + '.planning/phases/01-foundation/01-01-PLAN.md': PLAN_BARE_INT_TRUTH(1), + '.planning/phases/01-foundation/01-02-PLAN.md': PLAN_BARE_INT_TRUTH(2), + }); + + const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const out = JSON.parse(result.output); + assert.strictEqual( + out.cross_cutting_constraints, + 1, + 'numeric truth shared across plans must be surfaced (coerced), not dropped' + ); + + const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); + assert.ok(roadmap.includes('Cross-cutting constraints:'), + 'cross-cutting subsection present'); + assert.ok(/-\s*42\b/.test(roadmap), + 'numeric truth "42" surfaced as a string in the roadmap'); + }); + + test('kv-shaped truth with numeric value uses title and contributes to cross-cutting analysis', () => { + // Both plans share `{ title: 'shared-rule', count: 3 }`. Pre-fix: + // typeof === 'object' so silently skipped → constraint dropped. + // Post-fix: title extracted, surfaced in cross-cutting subsection. + tmpDir = makePlanProject({ + '.planning/ROADMAP.md': ROADMAP, + '.planning/phases/01-foundation/01-01-PLAN.md': PLAN_NUMERIC_TRUTH(1, 'shared-rule'), + '.planning/phases/01-foundation/01-02-PLAN.md': PLAN_NUMERIC_TRUTH(2, 'shared-rule'), + }); + + const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const out = JSON.parse(result.output); + // Both plans share two truths: the kv-shaped { title: 'shared-rule', ... } + // and the bare numeric 42. Pre-fix neither would survive the typeof guard; + // post-fix both are coerced and surfaced. + assert.strictEqual( + out.cross_cutting_constraints, + 2, + 'kv-shaped truth and numeric truth both surface, not dropped' + ); + + const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); + assert.ok(roadmap.includes('shared-rule'), + 'title from kv-shaped truth surfaced in cross-cutting list'); + assert.ok(/-\s*42\b/.test(roadmap), + 'numeric truth surfaced as a string'); + }); +}); + +describe('bug #2770 — bare-int depends_on values parse as preserved strings', () => { + test('scalar bare-int depends_on parses as string "3" (not dropped, not numeric)', () => { + // Per issue title: a YAML scalar `depends_on: 3` must be preserved as the + // string "3". The frontmatter parser already returns strings here; this + // test pins the behaviour so a future "convert YAML scalars to numbers" + // optimization cannot silently regress dependency tracking. + const fm = extractFrontmatter([ + '---', + 'phase: "1"', + 'plan: "01"', + 'depends_on: 3', + '---', + 'body', + '', + ].join('\n')); + assert.strictEqual(typeof fm.depends_on, 'string', + 'scalar depends_on must remain a string after parse'); + assert.strictEqual(fm.depends_on, '3', + 'bare int 3 must be preserved as the string "3"'); + }); + + test('inline-array bare-int depends_on parses to ["3","4"] (preserved as strings)', () => { + const fm = extractFrontmatter([ + '---', + 'phase: "1"', + 'plan: "01"', + 'depends_on: [3, 4]', + '---', + 'body', + '', + ].join('\n')); + assert.ok(Array.isArray(fm.depends_on), + 'inline array depends_on must be an array'); + assert.deepStrictEqual(fm.depends_on, ['3', '4'], + 'bare ints in inline array must be preserved as strings — never dropped'); + // Critical: assert *length* matches input. A naive `if (typeof !== string) continue` + // would silently drop entries; we must coerce, not skip. + assert.strictEqual(fm.depends_on.length, 2, + 'no dependency may be silently dropped during coercion'); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3242-state-update-progress-trample.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3242-state-update-progress-trample (consolidation epic #1969 B3 #1972)", () => { +'use strict'; +// Regression tests for issue #3242 — two distinct bugs in state.cjs: +// +// Bug A: cmdStateUpdate("Last Activity", date) triggers a full disk-derived +// progress.* block rebuild via readModifyWriteStateMd → syncStateFrontmatter → +// buildStateFrontmatter, which tramples manually-curated cross-milestone counters +// in STATE.md frontmatter. A body-only field update must not modify progress.*. +// +// Bug B: buildStateFrontmatter (and the duplicate in cmdStateSync) derives +// progress.percent = completedPlans / totalPlans. When ROADMAP declares more +// phases than have dirs on disk, all plans being summarised gives percent: 100 +// even though half the phases are unrealised. The formula must be +// min(plan_fraction, phase_fraction) to reflect true completion. + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); +const { extractFrontmatter } = require('../gsd-core/bin/lib/frontmatter.cjs'); + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Build a minimal STATE.md body with frontmatter that has curated progress.*. + * The progress values are cross-milestone aggregates that must NOT be overwritten + * by a body-only field update. + */ +function buildStateWithCuratedProgress(opts) { + const { + completedPlans = 22, + totalPlans = 22, + completedPhases = 6, + totalPhases = 12, + percent = 50, + lastActivity = '2026-01-01', + } = opts || {}; + + return [ + '---', + 'gsd_state_version: 1.0', + 'status: executing', + 'progress:', + ` total_phases: ${totalPhases}`, + ` completed_phases: ${completedPhases}`, + ` total_plans: ${totalPlans}`, + ` completed_plans: ${completedPlans}`, + ` percent: ${percent}`, + '---', + '', + '# GSD State', + '', + '## Configuration', + 'Current Phase: 6', + 'Current Phase Name: test-phase', + 'Total Plans in Phase: 4', + 'Current Plan: 1', + 'Status: Executing Phase 6', + `Last Activity: ${lastActivity}`, + '', + ].join('\n'); +} + +/** + * Write a ROADMAP.md with `numPhases` phase headings (matching `## Phase N:` pattern). + * Only `numRealizedDirs` phase dirs will have plan/summary files on disk. + */ +function buildRoadmap(numPhases) { + const lines = ['# ROADMAP', '', '## Milestone v1.0', '']; + for (let i = 1; i <= numPhases; i++) { + lines.push(`### Phase ${i}: phase-${i}`); + lines.push(''); + } + return lines.join('\n'); +} + +/** + * Create phase dirs with full plan+summary coverage for the first `count` phases. + * Each dir gets 1 PLAN + 1 SUMMARY so the disk-scan treats them as complete. + */ +function createPhaseDirs(phasesDir, count) { + for (let i = 1; i <= count; i++) { + const dir = path.join(phasesDir, String(i).padStart(2, '0')); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, `01-PLAN.md`), `# Plan\n`); + fs.writeFileSync(path.join(dir, `01-SUMMARY.md`), `# Summary\n`); + } +} + +function createPhasePlanOnlyDirs(phasesDir, count) { + for (let i = 1; i <= count; i++) { + const dir = path.join(phasesDir, String(i).padStart(2, '0')); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, `01-PLAN.md`), `# Plan\n`); + } +} + +function readPersistedProgress(statePath) { + const fm = extractFrontmatter(fs.readFileSync(statePath, 'utf-8')); + assert.ok(fm.progress, 'persisted frontmatter must have a progress block'); + return Object.fromEntries( + Object.entries(fm.progress).map(([key, value]) => [key, Number(value)]), + ); +} + +function assertProgressEquals(actual, expected) { + for (const [key, value] of Object.entries(expected)) { + assert.strictEqual( + actual[key], + value, + `persisted progress.${key} expected ${value}, got ${actual[key]}`, + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Bug A: state.update must not trample curated progress.* frontmatter +// ───────────────────────────────────────────────────────────────────────────── + +describe('#3242 Bug A: body-only state.update preserves curated progress frontmatter', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('state.update "Last Activity" does not overwrite progress.completed_plans', (_t) => { + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + fs.writeFileSync(statePath, buildStateWithCuratedProgress({ + completedPlans: 22, + totalPlans: 22, + completedPhases: 6, + totalPhases: 12, + percent: 50, + lastActivity: '2026-01-01', + })); + + // Write 6 phase dirs with full coverage — disk would report 6/6 phases done, + // 6/6 plans done (percent=100 from plans-only formula), but frontmatter says 50%. + const phasesDir = path.join(tmpDir, '.planning', 'phases'); + createPhaseDirs(phasesDir, 6); + + const updateResult = runGsdTools( + ['state', 'update', 'Last Activity', '2026-05-07'], + tmpDir, + ); + assert.ok(updateResult.success, `state update failed: ${updateResult.error}`); + + // Read back and assert via state json (JSON return value, not raw file grep) + const jsonResult = runGsdTools('state json', tmpDir); + assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`); + + const fm = JSON.parse(jsonResult.output); + assert.ok(fm.progress, 'frontmatter must have a progress block'); + + // completed_plans must NOT have been trampled to 6 (disk reality) from the + // curated 22 that was stored in the frontmatter before the update. + assert.strictEqual( + fm.progress.completed_plans, + 22, + `state.update "Last Activity" must not overwrite curated progress.completed_plans ` + + `(was 22, got ${fm.progress.completed_plans})`, + ); + + // total_phases must NOT have been trampled to 6 (disk dirs) from curated 12. + assert.strictEqual( + fm.progress.total_phases, + 12, + `state.update "Last Activity" must not overwrite curated progress.total_phases ` + + `(was 12, got ${fm.progress.total_phases})`, + ); + + // percent must NOT have been trampled to 100 (plan-only formula on 6 realized dirs). + assert.strictEqual( + fm.progress.percent, + 50, + `state.update "Last Activity" must not overwrite curated progress.percent ` + + `(was 50, got ${fm.progress.percent})`, + ); + }); + + test('state.update "Last Activity" updates the body field itself', () => { + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + fs.writeFileSync(statePath, buildStateWithCuratedProgress({ lastActivity: '2026-01-01' })); + + const updateResult = runGsdTools( + ['state', 'update', 'Last Activity', '2026-05-07'], + tmpDir, + ); + assert.ok(updateResult.success, `state update failed: ${updateResult.error}`); + + // Assert via structured JSON output — not raw file text scanning. + // state json extracts Last Activity from the body and surfaces it as + // fm.last_activity, matching the no-source-grep testing standard. + const jsonResult = runGsdTools('state json', tmpDir); + assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`); + const fm = JSON.parse(jsonResult.output); + assert.strictEqual( + fm.last_activity, + '2026-05-07', + 'state.update should have written the new date to the Last Activity body field', + ); + }); + + test('state.update "Progress" resyncs progress frontmatter from the updated body', () => { + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + fs.writeFileSync(statePath, buildStateWithCuratedProgress({ + completedPlans: 22, + totalPlans: 22, + completedPhases: 6, + totalPhases: 12, + percent: 50, + }).replace('Last Activity: 2026-01-01\n', 'Last Activity: 2026-01-01\nProgress: [█████░░░░░] 50%\n')); + + const updateResult = runGsdTools( + ['state', 'update', 'Progress', '[████████░░] 80%'], + tmpDir, + ); + assert.ok(updateResult.success, `state update failed: ${updateResult.error}`); + + const jsonResult = runGsdTools('state json', tmpDir); + assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`); + const fm = JSON.parse(jsonResult.output); + assert.strictEqual(fm.progress.percent, 80); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// #1264: state.patch must apply the same progress preservation policy +// ───────────────────────────────────────────────────────────────────────────── + +describe('#1264: state.patch preserves curated progress frontmatter for non-progress fields', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('query state.patch of Current Phase preserves persisted progress.* values', () => { + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + const curatedProgress = { + total_phases: 4, + completed_phases: 3, + total_plans: 11, + completed_plans: 11, + percent: 75, + }; + fs.writeFileSync(statePath, buildStateWithCuratedProgress({ + completedPlans: curatedProgress.completed_plans, + totalPlans: curatedProgress.total_plans, + completedPhases: curatedProgress.completed_phases, + totalPhases: curatedProgress.total_phases, + percent: curatedProgress.percent, + })); + + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + buildRoadmap(5), + ); + createPhasePlanOnlyDirs(path.join(tmpDir, '.planning', 'phases'), 5); + + const patchResult = runGsdTools([ + 'query', + 'state.patch', + JSON.stringify({ 'Current Phase': '08.2' }), + ], tmpDir); + assert.ok(patchResult.success, `state patch failed: ${patchResult.error}`); + + const output = JSON.parse(patchResult.output); + assert.deepEqual(output.updated, ['Current Phase']); + + const progress = readPersistedProgress(statePath); + assertProgressEquals(progress, curatedProgress); + }); + + test('query state.patch of Total Plans in Phase still resyncs persisted progress.* from the updated body', () => { + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + fs.writeFileSync(statePath, buildStateWithCuratedProgress({ + completedPlans: 22, + totalPlans: 22, + completedPhases: 6, + totalPhases: 12, + percent: 50, + })); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + buildRoadmap(8), + ); + createPhasePlanOnlyDirs(path.join(tmpDir, '.planning', 'phases'), 8); + + const patchResult = runGsdTools([ + 'query', + 'state.patch', + JSON.stringify({ 'Total Plans in Phase': '8' }), + ], tmpDir); + assert.ok(patchResult.success, `state patch failed: ${patchResult.error}`); + + const output = JSON.parse(patchResult.output); + assert.deepEqual(output.updated, ['Total Plans in Phase']); + + const progress = readPersistedProgress(statePath); + assert.strictEqual(progress.total_plans, 8); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Bug B: progress.percent must use min(plan_fraction, phase_fraction) +// ───────────────────────────────────────────────────────────────────────────── + +describe('#3242 Bug B: progress.percent reflects phase fraction when ROADMAP declares future phases', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('12 declared phases / 6 realized / 6/6 plans done → percent is 50, not 100', (_t) => { + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + + // Body: 6 realized phases visible to disk scan. + // Frontmatter: intentionally absent so buildStateFrontmatter runs fresh. + fs.writeFileSync(statePath, [ + '# GSD State', + '', + '## Configuration', + 'Current Phase: 6', + 'Current Phase Name: test-phase-6', + 'Total Plans in Phase: 1', + 'Current Plan: 1', + 'Status: Executing Phase 6', + 'Last Activity: 2026-01-01', + '', + ].join('\n')); + + // ROADMAP with 12 phase headings — only 6 will have dirs on disk + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + buildRoadmap(12), + ); + + // 6 fully-realized phases (all plans have summaries) + const phasesDir = path.join(tmpDir, '.planning', 'phases'); + createPhaseDirs(phasesDir, 6); + + // state json rebuilds frontmatter from disk+body — this exercises buildStateFrontmatter + const jsonResult = runGsdTools('state json', tmpDir); + assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`); + + const fm = JSON.parse(jsonResult.output); + assert.ok(fm.progress, 'frontmatter must have a progress block'); + + // ROADMAP declares 12 phases; only 6 exist on disk → totalPhases = 12 + assert.strictEqual( + fm.progress.total_phases, + 12, + `total_phases must reflect ROADMAP-declared count (12), got ${fm.progress.total_phases}`, + ); + + // 6 of 12 phases realized → phase_fraction = 50% + // 6/6 plans done → plan_fraction = 100% + // percent = min(100, 50) = 50 + assert.strictEqual( + fm.progress.percent, + 50, + `percent must be 50 (phase fraction), not 100 (plan fraction) — ` + + `6 of 12 ROADMAP phases realized. Got ${fm.progress.percent}`, + ); + }); + + test('all phases realized: percent equals plan fraction (no artificial cap)', (_t) => { + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + + fs.writeFileSync(statePath, [ + '# GSD State', + '', + '## Configuration', + 'Current Phase: 3', + 'Current Phase Name: final-phase', + 'Total Plans in Phase: 1', + 'Current Plan: 1', + 'Status: Executing Phase 3', + 'Last Activity: 2026-01-01', + '', + ].join('\n')); + + // ROADMAP declares 3 phases; all 3 have dirs and full plan+summary coverage + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + buildRoadmap(3), + ); + + const phasesDir = path.join(tmpDir, '.planning', 'phases'); + createPhaseDirs(phasesDir, 3); + + const jsonResult = runGsdTools('state json', tmpDir); + assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`); + + const fm = JSON.parse(jsonResult.output); + assert.ok(fm.progress, 'frontmatter must have progress block'); + + // 3/3 phases done → phase_fraction = 100% + // 3/3 plans done → plan_fraction = 100% + // percent = min(100, 100) = 100 + assert.strictEqual( + fm.progress.percent, + 100, + `percent must be 100 when all phases are realized and all plans summarized`, + ); + }); + + test('state sync also reflects phase-fraction-capped percent in body Progress field', () => { + // state sync updates the body's Progress: field — it must use the same capped formula + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + + fs.writeFileSync(statePath, [ + '# GSD State', + '', + '## Configuration', + 'Current Phase: 6', + 'Current Phase Name: phase-6', + 'Total Plans in Phase: 1', + 'Current Plan: 1', + 'Status: Executing Phase 6', + 'Last Activity: 2026-01-01', + 'Progress: [░░░░░░░░░░] 0%', + '', + ].join('\n')); + + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + buildRoadmap(12), + ); + + const phasesDir = path.join(tmpDir, '.planning', 'phases'); + createPhaseDirs(phasesDir, 6); + + const syncResult = runGsdTools('state sync', tmpDir); + assert.ok(syncResult.success, `state sync failed: ${syncResult.error}`); + + // Read the body's Progress field via state json (JSON output is authoritative) + const jsonResult = runGsdTools('state json', tmpDir); + assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`); + + const fm = JSON.parse(jsonResult.output); + assert.ok(fm.progress, 'frontmatter must have progress block'); + + // state sync wrote a Progress: body field; state json re-derives percent from disk. + // Both must agree: 50%, not 100%. + assert.strictEqual( + fm.progress.percent, + 50, + `state sync must cap percent at phase fraction (50%), got ${fm.progress.percent}`, + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/feat-3594-parser-adversarial-frontmatter.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:feat-3594-parser-adversarial-frontmatter (consolidation epic #1969 B3 #1972)", () => { +/** + * Adversarial frontmatter-parser tests (#3594). + * + * Loads each file in `tests/fixtures/adversarial/frontmatter/` and pins + * the invariants `extractFrontmatter()` must satisfy. The fixtures + * encode hostile-but-realistic input shapes (duplicate keys, CRLF + * endings, unclosed blocks, Unicode, null bytes, huge but bounded + * payloads) that the parser will see in the wild because users edit + * planning files with multiple tools. + * + * Per CONTRIBUTING.md §"Testing Standards / Parser and project-file + * inputs", these are typed-IR assertions on parser return values — + * not prose-grep on rendered output. Property-style invariants for + * the roadmap parser live in + * `tests/feat-3594-parser-property-style.test.cjs`. + */ + +'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 { extractFrontmatter } = require('../gsd-core/bin/lib/frontmatter.cjs'); + +const FIXTURE_DIR = path.join(__dirname, 'fixtures', 'adversarial', 'frontmatter'); + +function loadFixture(name) { + // Read as buffer first so null bytes survive into the string. The + // CRLF fixture also requires we do NOT normalize line endings on read. + return fs.readFileSync(path.join(FIXTURE_DIR, name), 'utf-8'); +} + +describe('feat-3594: frontmatter parser handles duplicate keys deterministically', () => { + test('duplicate keys collapse to a single deterministic winner (last-wins is the current contract)', () => { + const content = loadFixture('duplicate-keys.md'); + const fm = extractFrontmatter(content); + + // The parser MUST return a single value per key — not an array of + // both, not a half-formed entry. Whichever value wins, the test pins + // the current behavior so a silent semantics change is a test failure. + assert.equal(typeof fm.title, 'string', 'title must be a string, not an array or object'); + assert.equal(typeof fm.status, 'string', 'status must be a string'); + // Current parser behavior: the second occurrence wins because each + // key: line overwrites the previous in the same indent context. + // Pin it so a change to first-wins becomes visible. + assert.equal(fm.title, 'Second', 'duplicate-key collapse must be last-wins (current contract)'); + assert.equal(fm.status, 'blocked', 'duplicate-key collapse must be last-wins (current contract)'); + // Untouched keys round-trip cleanly. + assert.equal(fm.phase, '01'); + }); +}); + +describe('feat-3594: frontmatter parser handles CRLF endings without bleed', () => { + test('CRLF-terminated frontmatter parses without trailing \\r in values', () => { + const content = loadFixture('crlf-mixed.md'); + const fm = extractFrontmatter(content); + // Each value MUST be \r-free. A bug in `\r?\n` handling would leak + // \r into the captured group. + assert.equal(fm.title, 'CRLF Title'); + assert.equal(fm.phase, '02'); + assert.ok(!/\r/.test(JSON.stringify(fm)), 'no \\r should appear in any parsed value'); + // Array items must also be \r-free. + assert.deepEqual(fm.plans, ['02-01', '02-02']); + }); +}); + +describe('feat-3594: frontmatter parser handles unclosed blocks safely', () => { + test('unclosed frontmatter block returns empty object, not partial parse', () => { + const content = loadFixture('unclosed-block.md'); + const fm = extractFrontmatter(content); + // The current contract: if the closing `---` is missing, the regex + // doesn't match and the parser returns {}. The test pins that — + // a partial parse (returning {title: 'Unclosed Block'}) would be a + // silent data-leak from the body into "frontmatter." + assert.deepEqual(fm, {}, 'unclosed block must yield empty frontmatter, not a partial parse'); + }); +}); + +describe('feat-3594: frontmatter parser preserves Unicode round-trip', () => { + test('non-ASCII keys and values survive parsing', () => { + const content = loadFixture('unicode-keys-and-values.md'); + const fm = extractFrontmatter(content); + assert.equal(fm.title, '日本語のタイトル'); + // The parser's key regex is /^(\s*)([a-zA-Z0-9_-]+):.../ so non-ASCII + // keys (like `相:`) won't be captured. Pin that current behavior so + // a future broadening to allow Unicode keys is visible (and so the + // ASCII-only contract is asserted, not silently relied on). + assert.equal(fm['相'], undefined, 'parser currently only recognizes ASCII keys (regression guard)'); + // The status field has an emoji — must survive. + assert.equal(fm.status, '🚧 in-flight'); + // Inline array with Greek letters. + assert.deepEqual(fm.tags, ['α', 'β', 'γ']); + }); +}); + +describe('feat-3594: frontmatter parser handles null bytes without truncation', () => { + test('null byte in a value is preserved or normalized, never silently truncates the rest', () => { + const content = loadFixture('null-byte-value.md'); + const fm = extractFrontmatter(content); + // The parser MUST NOT crash. It MUST NOT truncate the value at the + // null byte AND continue parsing as if the rest of the line never + // existed. We pin: (a) the title still parses, (b) the phase key + // following the null-byte line still parses (no early-termination), + // (c) the null-byte value itself is a string. + assert.equal(fm.title, 'Has null byte'); + assert.equal(fm.phase, '05', 'parser must continue past the null-byte line, not silently stop'); + assert.equal(typeof fm.weird, 'string'); + // The exact null-handling is documented by whatever the current + // parser does: either preserve the \x00 or strip it. Test pins one. + assert.ok(fm.weird.includes('before'), 'value before the null byte must be retained'); + }); +}); + +describe('feat-3594: frontmatter parser handles bounded-large inputs in reasonable time', () => { + test('64KB frontmatter with 2000 array items parses and returns the right shape', () => { + const content = loadFixture('huge-bounded.md'); + const fm = extractFrontmatter(content); + assert.equal(fm.phase, '06'); + assert.ok(Array.isArray(fm.plans), 'plans must be parsed as an array'); + assert.equal(fm.plans.length, 2000, 'all 2000 array items must be captured'); + assert.equal(fm.plans[0], 'item-00000'); + assert.equal(fm.plans[1999], 'item-01999'); + }); +}); + +// ─── Cross-cutting invariants over the whole fixture corpus ──────────────── + +describe('feat-3594: frontmatter parser does not throw on ANY corpus fixture', () => { + // Property-style: whatever weirdness lives in the corpus, extractFrontmatter + // must return an object — never throw, never return undefined/null. This is + // the floor every individual fixture also satisfies, but checking it as a + // sweep catches a future fixture addition where the author forgets to write + // a per-file test. + const fixtures = fs.readdirSync(FIXTURE_DIR).filter((f) => f.endsWith('.md') && f !== 'README.md'); + for (const fixture of fixtures) { + test(`fixture "${fixture}" — extractFrontmatter returns a plain object without throwing`, () => { + const content = loadFixture(fixture); + let fm; + assert.doesNotThrow(() => { fm = extractFrontmatter(content); }, `extractFrontmatter must not throw on ${fixture}`); + assert.equal(typeof fm, 'object', `${fixture}: result must be an object`); + assert.notEqual(fm, null, `${fixture}: result must not be null`); + assert.equal(Array.isArray(fm), false, `${fixture}: result must not be an array`); + }); + } +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/feat-3594-parser-property-style.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:feat-3594-parser-property-style (consolidation epic #1969 B3 #1972)", () => { +/** + * Deterministic property-style parser tests (#3594). + * + * Follows TEST-EXAMPLES.md §"Deterministic Property-Style Parser Test": + * a bounded, seeded loop generates many malformed inputs and asserts a + * single invariant against each. On failure the seed and case index + * are printed so the failing input can be reproduced exactly. + * + * The generator is a small mulberry32 PRNG so this file has zero + * external dependencies and is fully reproducible across Node versions. + * Each test pins its own seed and case count; bumping either is a + * deliberate test change, not a flake source. + * + * Invariant tested (frontmatter): for any random text the parser must + * either return a plain object or throw — never return null/undefined, + * never hang, never propagate "Cannot read properties of …" V8 prose. + */ + +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); + +const { extractFrontmatter } = require('../gsd-core/bin/lib/frontmatter.cjs'); + +/** + * mulberry32 — small fast deterministic PRNG. Seed in, [0,1) out. + * Same input always produces the same sequence across Node versions. + */ +function mulberry32(seed) { + let a = seed >>> 0; + return function next() { + a = (a + 0x6D2B79F5) | 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** + * Build a single malformed-ish frontmatter input. Components are mixed + * deterministically by the supplied PRNG. + */ +function makeInput(rng) { + const fragments = [ + '---\n', + 'title: Generated\n', + 'phase: 99\n', + 'plans:\n - a\n - b\n', + 'extra: \xff\xfe\xfd\n', // invalid UTF-8 bytes + 'unicode: 日本語\n', + 'crlf: ends\r\nin\rcr\n', + ' indented_key: value\n', + 'duplicate: first\nduplicate: second\n', + 'sparse:\n\n\n', + 'malformed_array: [a, "b", c\n', // unclosed inline array + 'null_byte: before\x00after\n', + ]; + // Pick a random subset of fragments in random order. Always include + // the opening `---`. Closing `---` is included by 50% of cases so we + // exercise both well-formed and unclosed shapes. + const head = fragments[0]; + const rest = shuffle(fragments.slice(1), rng).slice(0, 1 + Math.floor(rng() * 6)); + const closing = rng() < 0.5 ? '---\n' : ''; + return head + rest.join('') + closing + '\nBody.\n'; +} + +/** + * Fisher-Yates shuffle driven by the supplied PRNG. Returns a new + * array; does not mutate the input. Replaces the previous + * `arr.sort(() => rng() - 0.5)` which was non-transitive — the + * resulting order depended on V8's sort implementation, not only on + * the seed, so failing cases were unreproducible across Node versions. + * Fisher-Yates is O(n), transitive (no comparator), and depends only + * on the RNG output. Codex review on PR #3633 / #3594. + */ +function shuffle(arr, rng) { + const out = arr.slice(); + for (let i = out.length - 1; i > 0; i--) { + const j = Math.floor(rng() * (i + 1)); + const tmp = out[i]; + out[i] = out[j]; + out[j] = tmp; + } + return out; +} + +test('extractFrontmatter is total over 500 deterministic random inputs (seed=1234)', () => { + const seed = 1234; + const rng = mulberry32(seed); + const count = 500; + for (let i = 0; i < count; i++) { + const input = makeInput(rng); + let result; + try { + result = extractFrontmatter(input); + } catch (err) { + // If the parser throws, the failure must be a controlled one — + // not a V8 "Cannot read properties of undefined" that signals a + // null-deref bug. Print the seed and case index so the input + // can be reproduced exactly. + const msg = String((err && err.message) || err); + assert.doesNotMatch( + msg, + /Cannot read propert/i, + `seed=${seed} case=${i}: parser must not propagate null-deref TypeError; input=${JSON.stringify(input)}`, + ); + continue; + } + // No throw: result MUST be a plain object (not null, not array, not + // primitive). Print enough on failure to reproduce. + assert.equal(typeof result, 'object', `seed=${seed} case=${i}: result must be object, got ${typeof result}`); + assert.notEqual(result, null, `seed=${seed} case=${i}: result must not be null`); + assert.equal(Array.isArray(result), false, `seed=${seed} case=${i}: result must not be an array`); + } +}); + +test('extractFrontmatter handles large frontmatter blocks without body bleed', () => { + // Deterministic large-input coverage replaces the former wall-clock ratio + // guard. Timing assertions are host-sensitive; this pins the parser contract + // instead: parse every frontmatter line once and stop at the first closing + // delimiter before the body. + + /** Build a frontmatter string with exactly `lineCount` key:value lines. */ + function buildScaleInput(lineCount) { + let s = '---\n'; + for (let i = 0; i < lineCount; i++) { + s += `key${i}: value${i}\n`; + } + return s + '---\nBody.\n'; + } + + for (const lineCount of [20, 200, 2000]) { + const result = extractFrontmatter(buildScaleInput(lineCount) + 'body_key: not-frontmatter\n'); + assert.equal(Object.keys(result).length, lineCount); + assert.equal(result.key0, 'value0'); + assert.equal(result[`key${lineCount - 1}`], `value${lineCount - 1}`); + assert.equal(result.body_key, undefined); + } +}); + }); +} diff --git a/tests/gap-checker.property.test.cjs b/tests/gap-checker.property.test.cjs index 4c07d7359..3979b09d4 100644 --- a/tests/gap-checker.property.test.cjs +++ b/tests/gap-checker.property.test.cjs @@ -218,3 +218,389 @@ describe('#1269 normalizePhaseReqIds — range expansion properties', () => { })); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-447-gap-analysis-phase-req-ids.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-447-gap-analysis-phase-req-ids (consolidation epic #1969 B3 #1972)", () => { +'use strict'; + +/** + * Bug #447: plan-phase §13e gap-analysis ignores phase_req_ids → false-positive + * coverage gaps. + * + * Root cause: runGapAnalysis() diffs the ENTIRE REQUIREMENTS.md against the + * phase's plans, with no awareness of phase_req_ids. §13 (Requirements Coverage + * Gate) skips when phase_req_ids is null/TBD, but §13e never inherited that + * scoping contract — so a phase that maps no requirements reports every + * unrelated project REQ-ID as "Not covered". + * + * Fix: teach the gap-analysis CLI a --phase-req-ids option (the durable home for + * the scoping contract), mirroring §13: + * - null / TBD / empty → skip the REQUIREMENTS.md comparison entirely + * (CONTEXT.md decisions are still reported). + * - explicit ID list → restrict the comparison to those IDs. + * - flag absent → backward-compatible (compare the whole file). + */ + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); +const { normalizePhaseReqIds } = require('../gsd-core/bin/lib/gap-checker.cjs'); + +describe('gap-analysis --phase-req-ids scoping (#447)', () => { + let tmpDir; + let phaseDir; + + function writeRequirements(ids) { + const lines = ids.map((id, i) => `- [ ] **${id}** Requirement ${i + 1} description`); + fs.writeFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), + `# Requirements\n\n${lines.join('\n')}\n`); + } + + function writeContext(decisions) { + const dLines = decisions.map(d => `- **${d.id}:** ${d.text}`).join('\n'); + fs.writeFileSync(path.join(phaseDir, 'CONTEXT.md'), + `# Phase Context\n\n\n## Implementation Decisions\n\n${dLines}\n\n`); + } + + function writePlan(name, body) { + fs.writeFileSync(path.join(phaseDir, `${name}-PLAN.md`), body); + } + + function reqRows(out) { + return out.rows.filter(r => r.source === 'REQUIREMENTS.md').map(r => r.item); + } + + beforeEach(() => { + tmpDir = createTempProject(); + phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test'); + fs.mkdirSync(phaseDir, { recursive: true }); + const r = runGsdTools('config-ensure-section', tmpDir); + assert.ok(r.success, `config-ensure-section failed: ${r.error}`); + }); + + afterEach(() => cleanup(tmpDir)); + + // ── The core bug ───────────────────────────────────────────────────────────── + + test('phase mapping no REQs (--phase-req-ids TBD) reports zero REQUIREMENTS.md rows', () => { + // A REQUIREMENTS.md full of IDs that belong to OTHER phases/milestones. + writeRequirements(['BACK-01', 'WEB-03', 'API-07', 'DATA-02']); + writePlan('01', '# Plan 1\n\nStandalone phase, maps no project requirements.\n'); + + const r = runGsdTools( + ['gap-analysis', '--phase-dir', phaseDir, '--phase-req-ids', 'TBD'], tmpDir); + assert.ok(r.success, `gap-analysis failed: ${r.error}`); + const out = JSON.parse(r.output); + + assert.deepStrictEqual(reqRows(out), [], + 'a phase that maps no REQ-IDs must not report unrelated project requirements as gaps'); + assert.strictEqual(out.counts.uncovered, 0, + 'no false-positive "not covered" rows for an unmapped phase'); + }); + + test('--phase-req-ids null behaves the same as TBD (skip requirements)', () => { + writeRequirements(['BACK-01', 'WEB-03']); + writePlan('01', '# Plan\n\nNo mapped reqs.\n'); + + const r = runGsdTools( + ['gap-analysis', '--phase-dir', phaseDir, '--phase-req-ids', 'null'], tmpDir); + assert.ok(r.success, r.error); + const out = JSON.parse(r.output); + assert.deepStrictEqual(reqRows(out), []); + }); + + // ── Scoped to a mapped subset ──────────────────────────────────────────────── + + test('explicit ID list restricts the comparison to those REQ-IDs', () => { + writeRequirements(['REQ-01', 'REQ-02', 'REQ-03']); + // Plan covers REQ-01 only; REQ-02 is mapped to the phase but not yet addressed. + writePlan('01', '# Plan\n\nImplements REQ-01 only.\n'); + + const r = runGsdTools( + ['gap-analysis', '--phase-dir', phaseDir, '--phase-req-ids', 'REQ-01,REQ-02'], tmpDir); + assert.ok(r.success, r.error); + const out = JSON.parse(r.output); + + assert.deepStrictEqual(reqRows(out).sort(), ['REQ-01', 'REQ-02'], + 'only the phase-mapped REQ-IDs are considered; REQ-03 (another phase) is excluded'); + const req01 = out.rows.find(x => x.item === 'REQ-01'); + const req02 = out.rows.find(x => x.item === 'REQ-02'); + assert.strictEqual(req01.status, 'Covered'); + assert.strictEqual(req02.status, 'Not covered'); + }); + + test('JSON-array-ish value (["REQ-01"]) is tolerated and scoped', () => { + writeRequirements(['REQ-01', 'REQ-02']); + writePlan('01', '# Plan\n\nImplements REQ-01.\n'); + + const r = runGsdTools( + ['gap-analysis', '--phase-dir', phaseDir, '--phase-req-ids', '["REQ-01"]'], tmpDir); + assert.ok(r.success, r.error); + const out = JSON.parse(r.output); + assert.deepStrictEqual(reqRows(out), ['REQ-01']); + }); + + // ── CONTEXT.md decisions are unaffected by req scoping ─────────────────────── + + test('CONTEXT.md decisions are still reported when requirements are skipped', () => { + writeRequirements(['BACK-01', 'WEB-03']); + writeContext([{ id: 'D-01', text: 'Use a local notification daemon' }]); + writePlan('01', '# Plan\n\nUnrelated work, no decisions addressed.\n'); + + const r = runGsdTools( + ['gap-analysis', '--phase-dir', phaseDir, '--phase-req-ids', 'TBD'], tmpDir); + assert.ok(r.success, r.error); + const out = JSON.parse(r.output); + + assert.deepStrictEqual(reqRows(out), [], 'requirements skipped'); + const d01 = out.rows.find(x => x.item === 'D-01'); + assert.ok(d01, 'CONTEXT.md decision D-01 must still be reported'); + assert.strictEqual(d01.source, 'CONTEXT.md'); + assert.strictEqual(d01.status, 'Not covered'); + }); + + // ── Parser robustness (workflow passes the roadmap value verbatim) ─────────── + + test('whitespace/newline-separated IDs are tolerated and scoped', () => { + writeRequirements(['REQ-01', 'REQ-02', 'REQ-03']); + writePlan('01', '# Plan\n\nImplements the first one.\n'); + const r = runGsdTools( + ['gap-analysis', '--phase-dir', phaseDir, '--phase-req-ids', 'REQ-01 REQ-02\nREQ-03'], tmpDir); + assert.ok(r.success, r.error); + const out = JSON.parse(r.output); + assert.deepStrictEqual(reqRows(out).sort(), ['REQ-01', 'REQ-02', 'REQ-03']); + }); + + // ── Backward compatibility ─────────────────────────────────────────────────── + + test('flag absent → whole REQUIREMENTS.md is compared (unchanged behavior)', () => { + writeRequirements(['REQ-01', 'REQ-02']); + writePlan('01', '# Plan\n\nImplements REQ-01 only.\n'); + + const r = runGsdTools(['gap-analysis', '--phase-dir', phaseDir], tmpDir); + assert.ok(r.success, r.error); + const out = JSON.parse(r.output); + assert.deepStrictEqual(reqRows(out).sort(), ['REQ-01', 'REQ-02'], + 'with no --phase-req-ids, all requirements are still reported (back-compat)'); + }); + + // ── §13e wiring: init.plan-phase --pick phase_req_ids → gap-analysis ───────── + // Guards the exact query the workflow uses. `roadmap.get-phase` returns raw + // phase TEXT (not JSON), so --pick yields nothing there; the scoping value + // must come from `init.plan-phase`. This test would have caught using the + // wrong query (which silently skips requirements for every phase). + + function writeRoadmap(reqLine) { + fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap\n\n## Phase 1: Test Phase\n**Goal:** Do the thing\n${reqLine}**Success Criteria:**\n- It works\n`); + } + + test('init.plan-phase --pick phase_req_ids exposes the mapped IDs, and gap-analysis scopes to them', () => { + writeRoadmap('**Requirements:** REQ-01, REQ-02\n'); + writeRequirements(['REQ-01', 'REQ-02', 'REQ-03']); + writePlan('01', '# Plan\n\nImplements the first requirement only.\n'); + + const q = runGsdTools(['query', 'init.plan-phase', '1', '--pick', 'phase_req_ids'], tmpDir); + assert.ok(q.success, `init.plan-phase query failed: ${q.error}`); + const ids = q.output.trim(); + assert.match(ids, /REQ-01/, 'init.plan-phase must expose phase_req_ids (roadmap.get-phase does NOT)'); + + const r = runGsdTools(['gap-analysis', '--phase-dir', phaseDir, '--phase-req-ids', ids], tmpDir); + assert.ok(r.success, r.error); + const out = JSON.parse(r.output); + assert.deepStrictEqual(reqRows(out).sort(), ['REQ-01', 'REQ-02'], + 'gap report is scoped to the phase-mapped IDs; REQ-03 (another phase) is excluded'); + }); + + test('mapped REQ-ID absent from REQUIREMENTS.md appears as "Missing" row, not silently dropped', () => { + writeRequirements(['REQ-01']); + writePlan('01', '# Plan\n\nImplements REQ-01.\n'); + + const r = runGsdTools( + ['gap-analysis', '--phase-dir', phaseDir, '--phase-req-ids', 'REQ-01,REQ-99'], tmpDir); + assert.ok(r.success, r.error); + const out = JSON.parse(r.output); + + assert.deepStrictEqual(reqRows(out).sort(), ['REQ-01', 'REQ-99'], + 'REQ-99 (absent from REQUIREMENTS.md) must be present in the report, not silently dropped'); + const req99 = out.rows.find(x => x.item === 'REQ-99'); + assert.ok(req99, 'missing mapped ID must have an output row'); + assert.strictEqual(req99.status, 'Missing from REQUIREMENTS.md'); + assert.ok(out.counts.uncovered > 0, 'uncovered count must reflect the missing mapped ID'); + }); + + test('phase with no Requirements line → init.plan-phase yields empty → gap-analysis skips requirements', () => { + writeRoadmap(''); // no **Requirements:** line + writeRequirements(['REQ-01', 'REQ-02']); + writePlan('01', '# Plan\n\nStandalone phase.\n'); + + const q = runGsdTools(['query', 'init.plan-phase', '1', '--pick', 'phase_req_ids'], tmpDir); + assert.ok(q.success, q.error); + const ids = q.output.trim(); // expected empty + + // The workflow passes the (possibly empty) value through verbatim. + const r = runGsdTools(['gap-analysis', '--phase-dir', phaseDir, '--phase-req-ids', ids], tmpDir); + assert.ok(r.success, r.error); + const out = JSON.parse(r.output); + assert.deepStrictEqual(reqRows(out), [], + 'an unmapped phase reports no requirement gaps (the original #447 bug)'); + }); +}); + +/** + * #1269: `--phase-req-ids` range syntax (`-NN..-MM`) was treated + * as a literal ID, so a mapped range was reported as a coverage gap even when the + * individual IDs existed. normalizePhaseReqIds now expands a valid ascending + * same-prefix numeric range in place (preserving zero-pad width), and leaves any + * ambiguous/invalid range literal (fail-closed). These unit fixtures are folded + * here (the owning home for --phase-req-ids behavior) rather than a new + * bug-NNNN-* file, per the regression-test-placement policy. + */ +describe('#1269 — normalizePhaseReqIds range expansion', () => { + // ── The core bug: a range token must expand, not stay literal ──────────────── + + test('AC1: range + single ID expands in input order (was the literal-token bug)', () => { + // Pre-fix this returned ['SEL-01..SEL-03','TEST-01'] — the unexpanded range. + assert.deepStrictEqual( + normalizePhaseReqIds('SEL-01..SEL-03,TEST-01'), + ['SEL-01', 'SEL-02', 'SEL-03', 'TEST-01'], + 'a same-prefix ascending range must expand in place, preserving list order'); + }); + + test('AC2: zero-pad width is preserved across the expansion', () => { + assert.deepStrictEqual( + normalizePhaseReqIds('PREFIX-001..PREFIX-003'), + ['PREFIX-001', 'PREFIX-002', 'PREFIX-003']); + }); + + // ── AC3: existing behavior is unchanged ────────────────────────────────────── + + test('AC3: single-ID, comma/space/newline, and JSON-array-ish inputs unchanged', () => { + assert.deepStrictEqual(normalizePhaseReqIds('REQ-01'), ['REQ-01']); + assert.deepStrictEqual(normalizePhaseReqIds('REQ-01,REQ-02'), ['REQ-01', 'REQ-02']); + assert.deepStrictEqual(normalizePhaseReqIds('REQ-01 REQ-02'), ['REQ-01', 'REQ-02']); + assert.deepStrictEqual(normalizePhaseReqIds('REQ-01\nREQ-02'), ['REQ-01', 'REQ-02']); + assert.deepStrictEqual(normalizePhaseReqIds(['REQ-01', 'REQ-02']), ['REQ-01', 'REQ-02']); + assert.strictEqual(normalizePhaseReqIds(undefined), undefined); + assert.strictEqual(normalizePhaseReqIds(null), null); + assert.strictEqual(normalizePhaseReqIds('TBD'), null); + assert.strictEqual(normalizePhaseReqIds(''), null); + }); + + // ── AC4: invalid/ambiguous ranges stay LITERAL (fail-closed) ───────────────── + + test('AC4: mismatched-prefix range stays literal (no partial expansion)', () => { + assert.deepStrictEqual(normalizePhaseReqIds('SEL-01..TEST-03'), ['SEL-01..TEST-03']); + }); + + test('AC4: descending range stays literal', () => { + assert.deepStrictEqual(normalizePhaseReqIds('SEL-03..SEL-01'), ['SEL-03..SEL-01']); + }); + + test('AC4: non-numeric bound stays literal', () => { + assert.deepStrictEqual(normalizePhaseReqIds('SEL-0A..SEL-0C'), ['SEL-0A..SEL-0C']); + }); + + test('AC4: missing bound stays literal', () => { + assert.deepStrictEqual(normalizePhaseReqIds('SEL-01..'), ['SEL-01..']); + assert.deepStrictEqual(normalizePhaseReqIds('..SEL-03'), ['..SEL-03']); + }); + + test('AC4: an invalid range inside a mixed list stays literal while valid ones expand', () => { + assert.deepStrictEqual( + normalizePhaseReqIds('SEL-01..SEL-03,BAD-3..BAD-1'), + ['SEL-01', 'SEL-02', 'SEL-03', 'BAD-3..BAD-1']); + }); + + // ── Boundary fixtures ──────────────────────────────────────────────────────── + + test('boundary: single-element range (NN == MM)', () => { + assert.deepStrictEqual(normalizePhaseReqIds('SEL-02..SEL-02'), ['SEL-02']); + }); + + test('boundary: two-element range (NN == MM-1)', () => { + assert.deepStrictEqual(normalizePhaseReqIds('SEL-01..SEL-02'), ['SEL-01', 'SEL-02']); + }); + + test('boundary: differing zero-pad widths stay literal (fail-closed)', () => { + // Bounds of differing digit width are ambiguous: padding 'SEL-9' to width 2 + // would invent 'SEL-09', which may never appear unpadded in REQUIREMENTS. + // Fail closed — leave the whole token literal rather than guess. + assert.deepStrictEqual( + normalizePhaseReqIds('SEL-9..SEL-11'), + ['SEL-9..SEL-11']); + }); + + test('AC4: a range exceeding MAX_PHASE_REQ_RANGE stays literal (DoS guard)', () => { + // Same-width bounds (both 4 digits) so the differing-width guard does NOT fire + // first; span = 1001 - 1 + 1 = 1001 > MAX_PHASE_REQ_RANGE (1000) → the DoS cap + // is what keeps this literal. Isolates the cap branch from the width check. + const token = 'REQ-0001..REQ-1001'; + assert.deepStrictEqual(normalizePhaseReqIds(token), [token]); + }); + + test('multi-segment prefix with digits is handled (prefix compared verbatim)', () => { + assert.deepStrictEqual( + normalizePhaseReqIds('REQ2-01..REQ2-03'), + ['REQ2-01', 'REQ2-02', 'REQ2-03']); + }); +}); + +/** + * #1269 integration (AC5): the gap-analysis CLI must not flag a mapped range — + * or the IDs it expands to — as missing when those IDs exist in REQUIREMENTS.md. + */ +describe('#1269 — gap-analysis --phase-req-ids range (integration)', () => { + let tmpDir; + let phaseDir; + + function writeRequirements(ids) { + const lines = ids.map((id, i) => `- [ ] **${id}** Requirement ${i + 1} description`); + fs.writeFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), + `# Requirements\n\n${lines.join('\n')}\n`); + } + function writePlan(name, body) { + fs.writeFileSync(path.join(phaseDir, `${name}-PLAN.md`), body); + } + function reqRows(out) { + return out.rows.filter(r => r.source === 'REQUIREMENTS.md').map(r => r.item); + } + + beforeEach(() => { + tmpDir = createTempProject(); + phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test'); + fs.mkdirSync(phaseDir, { recursive: true }); + const r = runGsdTools('config-ensure-section', tmpDir); + assert.ok(r.success, `config-ensure-section failed: ${r.error}`); + }); + + afterEach(() => cleanup(tmpDir)); + + test('AC5: a mapped range is expanded and not flagged as missing when the IDs exist', () => { + writeRequirements(['SEL-01', 'SEL-02', 'SEL-03', 'TEST-01', 'OTHER-09']); + // The plan addresses each expanded SEL id and TEST-01. + writePlan('01', '# Plan\n\nImplements SEL-01, SEL-02, SEL-03, and TEST-01.\n'); + + const r = runGsdTools( + ['gap-analysis', '--phase-dir', phaseDir, '--phase-req-ids', 'SEL-01..SEL-03,TEST-01'], tmpDir); + assert.ok(r.success, r.error); + const out = JSON.parse(r.output); + + assert.deepStrictEqual(reqRows(out).sort(), ['SEL-01', 'SEL-02', 'SEL-03', 'TEST-01'], + 'the range expands to individual SEL IDs; the literal range token must NOT appear, and OTHER-09 (unmapped) is excluded'); + // The literal range token must never surface as a missing row. + assert.ok(!out.rows.some(x => x.item.includes('..')), + 'no range-literal row (e.g. "SEL-01..SEL-03") may be reported'); + assert.strictEqual(out.counts.uncovered, 0, + 'all expanded IDs exist in REQUIREMENTS.md and are covered — zero gaps'); + }); +}); + }); +} diff --git a/tests/graphify-command-cutover.test.cjs b/tests/graphify-command-cutover.test.cjs index 8f667b974..57fe4638f 100644 --- a/tests/graphify-command-cutover.test.cjs +++ b/tests/graphify-command-cutover.test.cjs @@ -551,3 +551,375 @@ describe('graphify cutover: registry entries correct', () => { assert.strictEqual(cmd.router, 'routeGraphifyCommand'); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-974-graphify-budget-missing-value.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-974-graphify-budget-missing-value (consolidation epic #1969 B3 #1972)", () => { +'use strict'; +/** + * bug-974-graphify-budget-missing-value.test.cjs + * + * Regression guard: `gsd-tools graphify query --budget` with no value + * following --budget must emit a USAGE error and exit non-zero. + * + * Bug: args[budgetIdx + 1] was undefined → parseInt(undefined, 10) → NaN. + * NaN is falsy so the budget guard `if (options.budget)` silently skipped + * trimming and the query ran unbounded with no warning. (#974) + * + * Test categories: + * 1. BEHAVIORAL (recording mock) — direct routeGraphifyCommand call, no I/O + * 2. SUBPROCESS — end-to-end via runGsdTools + * 3. PROPERTY — budget arg parser: non-numeric/absent → usage error, valid int → passes + */ + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); +const { + enableGraphify, + writeGraphJson, + SAMPLE_GRAPH, +} = require('./helpers/graphify.cjs'); +const fc = require('./helpers/fast-check-setup.cjs'); + +const { routeGraphifyCommand } = require('../gsd-core/bin/lib/graphify-command-router.cjs'); + +// ─── Recording-mock helpers (mirrors graphify-command-cutover.test.cjs) ──────── + +function makeGraphifyMock() { + const calls = []; + function recorder(name, ...fnArgs) { + const sentinel = { _mock: name, args: fnArgs }; + calls.push(sentinel); + return sentinel; + } + return { + calls, + mock: { + graphifyQuery: (cwd, term, opts) => recorder('graphifyQuery', cwd, term, opts), + graphifyStatus: (cwd) => recorder('graphifyStatus', cwd), + graphifyDiff: (cwd) => recorder('graphifyDiff', cwd), + graphifyBuild: (cwd) => recorder('graphifyBuild', cwd), + writeSnapshot: (cwd) => recorder('writeSnapshot', cwd), + }, + }; +} + +function makeErrorRecorder() { + const calls = []; + const fn = (msg, reason) => calls.push({ msg, reason }); + fn.calls = calls; + return fn; +} + +function runJsonErrors(args, tmpDir, env = {}) { + const result = runGsdTools(args, tmpDir, { ...env, GSD_JSON_ERRORS: '1' }); + assert.strictEqual(result.success, false, + `Expected failure with GSD_JSON_ERRORS=1 for args: ${args.join(' ')}\n` + + `stdout: ${result.output}\nstderr: ${result.error}`); + let parsed; + try { + parsed = JSON.parse(result.error); + } catch (e) { + throw new Error( + `GSD_JSON_ERRORS=1 must emit valid JSON on stderr.\n` + + `Args: ${args.join(' ')}\nstderr: ${result.error}\nparse error: ${e.message}`, + ); + } + return parsed; +} + +function assertTypedError(parsed, expectedReason, label) { + assert.strictEqual(parsed.ok, false, `${label}: error object must have ok: false`); + assert.strictEqual(parsed.reason, expectedReason, + `${label}: reason must be "${expectedReason}", got: ${parsed.reason}`); + assert.ok(typeof parsed.message === 'string' && parsed.message.length > 0, + `${label}: message must be a non-empty string`); +} + +// ─── 1. BEHAVIORAL — direct routeGraphifyCommand (recording mock, no I/O) ───── + +describe('bug #974: --budget missing value → usage error (unit, recording mock)', () => { + const CWD = '/fake/cwd'; + const RAW = false; + + // (a) --budget as last arg (no value following) + test('(a) --budget last arg (missing value) → error(USAGE); graphifyQuery NOT called', () => { + const { calls, mock } = makeGraphifyMock(); + const errFn = makeErrorRecorder(); + routeGraphifyCommand({ + args: ['graphify', 'query', 'myterm', '--budget'], + cwd: CWD, raw: RAW, error: errFn, _graphify: mock, + }); + assert.strictEqual(errFn.calls.length, 1, + `error must be called exactly once; got ${errFn.calls.length} calls`); + assert.strictEqual(errFn.calls[0].reason, 'usage', + `reason must be 'usage'; got: ${errFn.calls[0].reason}`); + assert.ok( + errFn.calls[0].msg.includes('graphify query'), + `usage message must mention "graphify query"; got: ${errFn.calls[0].msg}`, + ); + assert.strictEqual(calls.length, 0, + 'graphifyQuery must NOT be called when --budget has no value'); + }); + + // (b) --budget with a non-numeric value + test('(b) --budget foo (non-numeric value) → error(USAGE); graphifyQuery NOT called', () => { + const { calls, mock } = makeGraphifyMock(); + const errFn = makeErrorRecorder(); + routeGraphifyCommand({ + args: ['graphify', 'query', 'myterm', '--budget', 'foo'], + cwd: CWD, raw: RAW, error: errFn, _graphify: mock, + }); + assert.strictEqual(errFn.calls.length, 1, + `error must be called exactly once; got ${errFn.calls.length} calls`); + assert.strictEqual(errFn.calls[0].reason, 'usage', + `reason must be 'usage'; got: ${errFn.calls[0].reason}`); + assert.strictEqual(calls.length, 0, + 'graphifyQuery must NOT be called when --budget value is non-numeric'); + }); + + // (c) --budget 0 → 0 is a valid integer, must NOT error + test('(c) --budget 0 (explicit zero) → budget passed as 0, no error', () => { + const { calls, mock } = makeGraphifyMock(); + const errFn = makeErrorRecorder(); + routeGraphifyCommand({ + args: ['graphify', 'query', 'myterm', '--budget', '0'], + cwd: CWD, raw: RAW, error: errFn, _graphify: mock, + }); + assert.strictEqual(errFn.calls.length, 0, + `error must NOT be called for --budget 0; got: ${JSON.stringify(errFn.calls)}`); + assert.strictEqual(calls.length, 1, + 'graphifyQuery MUST be called for --budget 0'); + assert.strictEqual(calls[0]._mock, 'graphifyQuery'); + assert.strictEqual(calls[0].args[2].budget, 0, + `budget must be 0, got: ${calls[0].args[2].budget}`); + }); + + // (d) valid --budget 500 → regression: still works after fix + test('(d) --budget 500 (valid positive integer) → budget: 500, no error', () => { + const { calls, mock } = makeGraphifyMock(); + const errFn = makeErrorRecorder(); + routeGraphifyCommand({ + args: ['graphify', 'query', 'myterm', '--budget', '500'], + cwd: CWD, raw: RAW, error: errFn, _graphify: mock, + }); + assert.strictEqual(errFn.calls.length, 0, + `error must NOT be called for --budget 500; got: ${JSON.stringify(errFn.calls)}`); + assert.strictEqual(calls.length, 1); + assert.strictEqual(calls[0]._mock, 'graphifyQuery'); + assert.strictEqual(calls[0].args[2].budget, 500, + `budget must be integer 500; got: ${calls[0].args[2].budget}`); + assert.strictEqual(typeof calls[0].args[2].budget, 'number', + 'budget must be a number, not string'); + }); + +}); + +// ─── 2. SUBPROCESS — end-to-end via runGsdTools ─────────────────────────────── + +describe('bug #974: --budget missing value → usage error (subprocess)', () => { + let tmpDir; + let planningDir; + + beforeEach(() => { + tmpDir = createTempProject(); + planningDir = path.join(tmpDir, '.planning'); + enableGraphify(planningDir); + writeGraphJson(planningDir, SAMPLE_GRAPH); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('subprocess: --budget last arg → non-zero exit', () => { + const result = runGsdTools( + ['graphify', 'query', 'AuthService', '--budget'], + tmpDir, + ); + assert.strictEqual(result.success, false, + `--budget with no value must exit non-zero; stderr: ${result.error}`); + }); + + test('subprocess: --budget last arg → stderr contains usage hint', () => { + const result = runGsdTools( + ['graphify', 'query', 'AuthService', '--budget'], + tmpDir, + ); + assert.ok( + result.error.includes('Usage') || result.error.includes('graphify query'), + `stderr must contain usage hint; got: ${result.error}`, + ); + }); + + test('subprocess: --budget foo (non-numeric) → non-zero exit', () => { + const result = runGsdTools( + ['graphify', 'query', 'AuthService', '--budget', 'foo'], + tmpDir, + ); + assert.strictEqual(result.success, false, + `--budget with non-numeric value must exit non-zero; stderr: ${result.error}`); + }); + + test('subprocess: --budget last arg → GSD_JSON_ERRORS=1 → usage reason', () => { + const parsed = runJsonErrors( + ['graphify', 'query', 'AuthService', '--budget'], + tmpDir, + ); + assertTypedError(parsed, 'usage', '--budget missing value json-error'); + assert.ok( + parsed.message.includes('graphify query'), + `message must include "graphify query"; got: ${parsed.message}`, + ); + }); + + test('subprocess: --budget foo → GSD_JSON_ERRORS=1 → usage reason', () => { + const parsed = runJsonErrors( + ['graphify', 'query', 'AuthService', '--budget', 'foo'], + tmpDir, + ); + assertTypedError(parsed, 'usage', '--budget non-numeric json-error'); + }); + + // Regression: valid --budget still works after the fix + test('subprocess: --budget 500 (valid) → success, term echoed', () => { + const result = runGsdTools( + ['graphify', 'query', 'AuthService', '--budget', '500'], + tmpDir, + ); + assert.ok(result.success, + `--budget 500 must still succeed after fix; error: ${result.error}`); + const parsed = JSON.parse(result.output); + assert.strictEqual(parsed.term, 'AuthService', + 'term must be echoed in query response'); + assert.ok('nodes' in parsed, 'response must include nodes array'); + assert.ok('total_nodes' in parsed, 'response must include total_nodes field'); + }); +}); + +// ─── 3. PROPERTY — budget parse: non-numeric/absent → usage error, valid int → passes +// Per RULESET.TESTS.property-based-testing: parsing/budget-limit modules +// must include ≥1 fast-check property test. + +describe('bug #974: budget arg parser property tests', () => { + const CWD = '/fake/cwd'; + const RAW = false; + + // Property (a): any non-numeric, non-parseable string for --budget → usage error + // + // The budget-VALUE generator is constrained to genuinely non-numeric values. + // The .filter(v => Number.isNaN(parseInt(v, 10))) at the end is the authoritative + // gate: it excludes any value that parseInt(v, 10) would accept (e.g. "+5", "-3", + // " 7", "0x1a", "007"). This makes the property universally true rather than + // relying on a runtime skip inside the property body. + // The term is fixed as "myterm" — this property is about the BUDGET VALUE, not + // the term. + test('property: non-numeric --budget value always produces usage error', () => { + fc.assert( + fc.property( + fc.oneof( + // Alphabetic-start strings (parseInt('abc') = NaN) + fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9_-]{0,20}$/), + // Empty string (parseInt('') = NaN) + fc.constant(''), + // Strings starting with special chars — filtered below for safety + fc.stringMatching(/^[!@#$%^&*()_+={}\][|\\:;"'<,>?/~`][^\s]{0,10}$/), + ).filter(v => Number.isNaN(parseInt(v, 10))), + (badValue) => { + const { mock } = makeGraphifyMock(); + const errFn = makeErrorRecorder(); + routeGraphifyCommand({ + args: ['graphify', 'query', 'myterm', '--budget', badValue], + cwd: CWD, raw: RAW, error: errFn, _graphify: mock, + }); + assert.strictEqual(errFn.calls.length, 1, + `--budget "${badValue}" (NaN) must produce exactly one error call`); + assert.strictEqual(errFn.calls[0].reason, 'usage', + `--budget "${badValue}" error must have reason 'usage'; got: ${errFn.calls[0].reason}`); + }, + ), + // Explicit seed + numRuns for CI determinism. + { seed: 1001, numRuns: 200 }, + ); + }); + + // Example (b): --budget as last arg (missing value) → usage error, for fixed valid terms. + // Previously a property fuzz over a validTerm generator; replaced with deterministic + // examples because the regex `/^[A-Za-z0-9][A-Za-z0-9_.-]{0,29}$/` admitted single + // digits like "0" which are falsy and trigger the router's `if (!term)` guard instead + // of the budget-missing-value path, causing a spurious test failure (seed 1004, term + // "0"). These properties are about the BUDGET contract; the term is a fixed bystander. + test('example: --budget as last arg always produces usage error (fixed terms)', () => { + const FIXED_TERMS = ['someterm', 'AuthService', 'a1', 'graph-node_1', 'MyComponent']; + for (const term of FIXED_TERMS) { + const { mock } = makeGraphifyMock(); + const errFn = makeErrorRecorder(); + routeGraphifyCommand({ + args: ['graphify', 'query', term, '--budget'], + cwd: CWD, raw: RAW, error: errFn, _graphify: mock, + }); + assert.strictEqual(errFn.calls.length, 1, + `--budget as last arg: term=${JSON.stringify(term)}: must produce exactly one error call; got ${errFn.calls.length}`); + assert.strictEqual(errFn.calls[0].reason, 'usage', + `--budget as last arg: term=${JSON.stringify(term)}: reason must be 'usage'; got ${errFn.calls[0].reason}`); + } + }); + + // Property (c): valid positive integer strings → budget passed through as number, no error + test('property: positive integer string for --budget is always accepted', () => { + fc.assert( + fc.property( + fc.integer({ min: 1, max: 10_000_000 }), + (n) => { + const { calls, mock } = makeGraphifyMock(); + const errFn = makeErrorRecorder(); + routeGraphifyCommand({ + args: ['graphify', 'query', 'myterm', '--budget', String(n)], + cwd: CWD, raw: RAW, error: errFn, _graphify: mock, + }); + assert.strictEqual(errFn.calls.length, 0, + `--budget ${n} (valid positive integer) must not produce an error`); + assert.strictEqual(calls.length, 1, 'graphifyQuery must be called'); + assert.strictEqual(calls[0].args[2].budget, n, + `budget must equal ${n}; got: ${calls[0].args[2].budget}`); + assert.strictEqual(typeof calls[0].args[2].budget, 'number', + `budget must be a number, not string "${calls[0].args[2].budget}"`); + }, + ), + // Explicit seed + numRuns for CI determinism. + { seed: 1003, numRuns: 200 }, + ); + }); + + // Example (d): absence of --budget flag yields budget:null and no error, for fixed valid terms. + // Previously a property fuzz over a validTerm generator; replaced with deterministic + // examples for the same reason as example (b): single-digit terms like "0" are falsy + // and trigger the router's term-missing usage error, not the budget-absent path. + // These examples fix the term as clearly valid multi-char identifiers. + test('example: absence of --budget flag yields budget:null and no error (fixed terms)', () => { + const FIXED_TERMS = ['someterm', 'AuthService', 'a1', 'graph-node_1', 'MyComponent']; + for (const term of FIXED_TERMS) { + const { calls, mock } = makeGraphifyMock(); + const errFn = makeErrorRecorder(); + routeGraphifyCommand({ + args: ['graphify', 'query', term], + cwd: CWD, raw: RAW, error: errFn, _graphify: mock, + }); + assert.strictEqual(errFn.calls.length, 0, + `no --budget flag must not produce an error for term "${term}"`); + assert.strictEqual(calls.length, 1, + `graphifyQuery must be called for term "${term}"; got calls.length=${calls.length}`); + assert.strictEqual(calls[0].args[2].budget, null, + `absent --budget must pass budget:null; got: ${calls[0].args[2].budget}`); + } + }); +}); + }); +} diff --git a/tests/gsd-tools-path-refs.test.cjs b/tests/gsd-tools-path-refs.test.cjs index a3c2922c2..d2fe75f36 100644 --- a/tests/gsd-tools-path-refs.test.cjs +++ b/tests/gsd-tools-path-refs.test.cjs @@ -54,3 +54,401 @@ describe('command files: gsd-tools path references (#1766)', () => { ); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3584-runtime-slash-formatter.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3584-runtime-slash-formatter (consolidation epic #1969 B3 #1972)", () => { +/** + * Regression tests for bug #3584 + * + * Runtime/user-facing strings emitted by gsd-core/bin/lib/*.cjs hardcoded + * the deprecated `/gsd:` colon form (16 files, ~50 occurrences). After + * #2808 unified GSD installs to register skills under the hyphen form + * (`name: gsd-execute-phase`), pasting the emitted `/gsd:execute-phase` into + * Claude Code yields `Unknown command: /gsd:execute-phase. Did you mean + * /gsd-execute-phase?`. Codex installs require `$gsd-` (shell-var) form. + * + * Fix: a runtime-aware slash formatter (`runtime-slash.cjs`) is now the single + * source of truth for emitting `/gsd-` (hyphen) for skills-based runtimes + * and `$gsd-` for Codex. Tests assert on the formatter's typed output and + * — for the integration tests in `bug-3584-runtime-slash-emitters.test.cjs` — + * on the structured `--json` payloads from the runtime command handlers. + */ + +'use strict'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('path'); + +const ROOT = path.join(__dirname, '..'); +const { formatGsdSlash, resolveRuntime } = require( + path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-slash.cjs'), +); +const { cleanup } = require('./helpers.cjs'); + +describe('formatGsdSlash — runtime-aware slash command formatter', () => { + describe('hyphen-form runtimes (claude, cursor, opencode, kilo, etc.)', () => { + test('emits /gsd- for claude', () => { + assert.strictEqual(formatGsdSlash('execute-phase', 'claude'), '/gsd-execute-phase'); + }); + + test('emits /gsd- for cursor', () => { + assert.strictEqual(formatGsdSlash('plan-phase', 'cursor'), '/gsd-plan-phase'); + }); + + test('emits /gsd- for opencode', () => { + assert.strictEqual(formatGsdSlash('discuss-phase', 'opencode'), '/gsd-discuss-phase'); + }); + + test('emits /gsd- for kilo', () => { + assert.strictEqual(formatGsdSlash('health', 'kilo'), '/gsd-health'); + }); + + test('unknown runtime defaults to hyphen form', () => { + assert.strictEqual( + formatGsdSlash('new-project', 'some-future-runtime'), + '/gsd-new-project', + ); + }); + + test('null/undefined runtime defaults to hyphen form (claude)', () => { + assert.strictEqual(formatGsdSlash('new-milestone', null), '/gsd-new-milestone'); + assert.strictEqual(formatGsdSlash('new-milestone', undefined), '/gsd-new-milestone'); + }); + + test('runtime aliases for non-codex runtimes still emit hyphen form', () => { + assert.strictEqual(formatGsdSlash('new-project', 'claude-code'), '/gsd-new-project'); + assert.strictEqual(formatGsdSlash('new-project', 'gemini-cli'), '/gsd-new-project'); + assert.strictEqual(formatGsdSlash('new-project', 'opencode-cli'), '/gsd-new-project'); + }); + }); + + describe('codex shell-var form', () => { + test('emits $gsd- for codex', () => { + assert.strictEqual(formatGsdSlash('execute-phase', 'codex'), '$gsd-execute-phase'); + }); + + test('emits $gsd- for codex aliases (app/cli)', () => { + assert.strictEqual(formatGsdSlash('execute-phase', 'codex-app'), '$gsd-execute-phase'); + assert.strictEqual(formatGsdSlash('execute-phase', 'codex_cli'), '$gsd-execute-phase'); + }); + + test('codex output is lowercased', () => { + assert.strictEqual( + formatGsdSlash('Execute-Phase', 'codex'), + '$gsd-execute-phase', + ); + }); + }); + + describe('input normalization', () => { + test('strips existing /gsd: colon prefix', () => { + assert.strictEqual( + formatGsdSlash('/gsd:execute-phase', 'claude'), + '/gsd-execute-phase', + ); + }); + + test('strips existing /gsd- hyphen prefix (idempotent)', () => { + assert.strictEqual( + formatGsdSlash('/gsd-plan-phase', 'claude'), + '/gsd-plan-phase', + ); + }); + + test('strips bare gsd: prefix without leading slash', () => { + assert.strictEqual( + formatGsdSlash('gsd:new-project', 'claude'), + '/gsd-new-project', + ); + }); + + test('strips existing $gsd- shell prefix (codex idempotent)', () => { + assert.strictEqual( + formatGsdSlash('$gsd-execute-phase', 'codex'), + '$gsd-execute-phase', + ); + }); + + test('runtime swap: /gsd:execute-phase + codex → $gsd-execute-phase', () => { + assert.strictEqual( + formatGsdSlash('/gsd:execute-phase', 'codex'), + '$gsd-execute-phase', + ); + }); + + test('case-insensitive prefix stripping', () => { + assert.strictEqual( + formatGsdSlash('GSD:execute-phase', 'claude'), + '/gsd-execute-phase', + ); + }); + }); + + describe('defensive returns for unsafe inputs', () => { + test('non-string commandName returns input unchanged', () => { + assert.strictEqual(formatGsdSlash(null, 'claude'), null); + assert.strictEqual(formatGsdSlash(undefined, 'claude'), undefined); + assert.strictEqual(formatGsdSlash(42, 'claude'), 42); + }); + + test('empty string returns empty string', () => { + assert.strictEqual(formatGsdSlash('', 'claude'), ''); + }); + + test('whitespace-only string returns empty string (no spurious /gsd- emission)', () => { + assert.strictEqual(formatGsdSlash(' ', 'claude'), ''); + assert.strictEqual(formatGsdSlash('\t\n', 'codex'), ''); + }); + + test('degenerate prefix-only input returns empty (does NOT re-emit colon form)', () => { + // Regression guard for the CodeRabbit finding on the original PR: + // a previous fallback returned `commandName` unchanged when the bare + // tail was empty, which re-introduced the deprecated `/gsd:` shape for + // inputs like `/gsd:`, `gsd:`, or `gsd-`. The formatter must never + // emit the colon form — return empty so callers detect "no command" + // instead of receiving an unroutable string. + assert.strictEqual(formatGsdSlash('/gsd:', 'claude'), ''); + assert.strictEqual(formatGsdSlash('gsd:', 'claude'), ''); + assert.strictEqual(formatGsdSlash('gsd-', 'claude'), ''); + assert.strictEqual(formatGsdSlash('/gsd-', 'codex'), ''); + assert.strictEqual(formatGsdSlash('$gsd-', 'codex'), ''); + }); + + test('commands with arguments preserve the argument tail', () => { + // `execute-phase 03` is a valid call shape — the formatter only + // rewrites the command token; everything after the first whitespace + // belongs to the caller. + assert.strictEqual( + formatGsdSlash('execute-phase 03', 'claude'), + '/gsd-execute-phase 03', + ); + assert.strictEqual( + formatGsdSlash('/gsd:execute-phase 03', 'claude'), + '/gsd-execute-phase 03', + ); + }); + + test('codex form lowercases only the command token, not the argument tail', () => { + // Regression for codex review finding: a previous implementation + // lowercased the full input including arguments, which would corrupt + // Windows paths and case-sensitive flag values passed as args. + assert.strictEqual( + formatGsdSlash('Map-Codebase --paths C:\\Users\\Me\\Project', 'codex'), + '$gsd-map-codebase --paths C:\\Users\\Me\\Project', + ); + assert.strictEqual( + formatGsdSlash('execute-phase 03 --Name FooBar', 'codex'), + '$gsd-execute-phase 03 --Name FooBar', + ); + }); + + test('hyphen form preserves token case (it does not get lowercased)', () => { + // Symmetry with codex: only codex lowercases the token. Hyphen-form + // runtimes preserve whatever case the caller supplied for the token. + assert.strictEqual( + formatGsdSlash('Plan-Phase 03', 'claude'), + '/gsd-Plan-Phase 03', + ); + }); + }); +}); + +describe('formatGsdSlash — descriptor-driven commandStyle (ADR-857 phase 5c)', () => { + // These three assertions are the non-vacuous equivalence anchor for the + // descriptor-driven branch: the formatter reads commandStyle from the + // capability registry instead of hardcoding `if (rt === 'codex')`. + + test('codex (commandStyle=shell-var) → $gsd- with lowercased token', () => { + // The registry carries commandStyle=shell-var for codex. The formatter + // must look it up and emit the shell-var form, lowercasing only the token. + assert.strictEqual(formatGsdSlash('Foo', 'codex'), '$gsd-foo'); + }); + + test('slash-hyphen runtime (claude) → /gsd- with case-preserved token', () => { + // claude descriptor has commandStyle=slash-hyphen. Token case is preserved. + assert.strictEqual(formatGsdSlash('Foo', 'claude'), '/gsd-Foo'); + }); + + test('slash-hyphen runtime (cursor) → /gsd- with case-preserved token', () => { + // cursor descriptor has commandStyle=slash-hyphen. + assert.strictEqual(formatGsdSlash('Foo', 'cursor'), '/gsd-Foo'); + }); + + test('unknown runtime (no registry entry) → /gsd- default (slash-hyphen fallback)', () => { + // An unknown runtime has no descriptor entry → runtimes[rt] is undefined → + // style is undefined → not 'shell-var' → falls through to /gsd- default. + assert.strictEqual(formatGsdSlash('foo', 'doesnotexist'), '/gsd-foo'); + }); +}); + +describe('formatGsdSlash — registry-parity: prefix and casing are a pure function of commandStyle', () => { + // Non-vacuous proof that the REGISTRY drives the decision, not a hardcoded + // `rt === 'codex'` check. For every runtime id in capability-registry.cjs we + // derive the expected prefix and lowercasing linkage directly from the + // registry's commandStyle field and assert that formatGsdSlash matches. + // + // This fails if: + // (a) anyone reverts the formatter to a hardcode that diverges from the + // registry (e.g. a future runtime gets commandStyle=shell-var but the + // formatter still only special-cases 'codex'); or + // (b) a runtime's commandStyle is changed in the registry without + // formatGsdSlash following suit. + const { runtimes } = require( + path.join(ROOT, 'gsd-core', 'bin', 'lib', 'capability-registry.cjs'), + ); + + const TOKEN_MIXED = 'SomeCmd'; // mixed-case to distinguish lowercasing behaviour + + for (const [id, descriptor] of Object.entries(runtimes)) { + const style = descriptor.runtime.commandStyle; + + test(`${id}: commandStyle=${style} → formatGsdSlash prefix and casing match registry`, () => { + const result = formatGsdSlash(TOKEN_MIXED, id); + + if (style === 'shell-var') { + // shell-var runtimes must emit $gsd- + assert.ok( + result.startsWith('$gsd-'), + `[${id}] expected $gsd- prefix (commandStyle=shell-var), got: ${result}`, + ); + assert.strictEqual( + result, + `$gsd-${TOKEN_MIXED.toLowerCase()}`, + `[${id}] shell-var must lowercase the token`, + ); + } else { + // slash-hyphen (or any other non-shell-var value) must emit /gsd- + // with case preserved (no lowercasing) + assert.ok( + result.startsWith('/gsd-'), + `[${id}] expected /gsd- prefix (commandStyle=${style}), got: ${result}`, + ); + assert.strictEqual( + result, + `/gsd-${TOKEN_MIXED}`, + `[${id}] slash-hyphen must preserve token case`, + ); + } + }); + } +}); + +describe('resolveRuntime — env > config > default', () => { + test('process.env.GSD_RUNTIME wins over everything', () => { + const saved = process.env.GSD_RUNTIME; + try { + process.env.GSD_RUNTIME = 'codex'; + assert.strictEqual(resolveRuntime(null), 'codex'); + assert.strictEqual(resolveRuntime('/nonexistent'), 'codex'); + } finally { + if (saved === undefined) delete process.env.GSD_RUNTIME; + else process.env.GSD_RUNTIME = saved; + } + }); + + test('canonicalizes codex env aliases', () => { + const saved = process.env.GSD_RUNTIME; + try { + process.env.GSD_RUNTIME = 'codex-app'; + assert.strictEqual(resolveRuntime(null), 'codex'); + process.env.GSD_RUNTIME = 'codex_cli'; + assert.strictEqual(resolveRuntime('/nonexistent'), 'codex'); + } finally { + if (saved === undefined) delete process.env.GSD_RUNTIME; + else process.env.GSD_RUNTIME = saved; + } + }); + + test('defaults to claude when env is unset and projectDir missing', () => { + const saved = process.env.GSD_RUNTIME; + try { + delete process.env.GSD_RUNTIME; + assert.strictEqual(resolveRuntime(null), 'claude'); + assert.strictEqual(resolveRuntime(undefined), 'claude'); + } finally { + if (saved !== undefined) process.env.GSD_RUNTIME = saved; + } + }); + + test('reads config.runtime when env is unset and projectDir has a config', (t) => { + const fs = require('fs'); + const os = require('os'); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3584-')); + t.after(() => cleanup(tmp)); + + fs.mkdirSync(path.join(tmp, '.planning'), { recursive: true }); + fs.writeFileSync( + path.join(tmp, '.planning', 'config.json'), + JSON.stringify({ runtime: 'codex' }), + ); + + const saved = process.env.GSD_RUNTIME; + try { + delete process.env.GSD_RUNTIME; + assert.strictEqual(resolveRuntime(tmp), 'codex'); + } finally { + if (saved !== undefined) process.env.GSD_RUNTIME = saved; + } + }); + + test('canonicalizes codex config aliases', (t) => { + const fs = require('fs'); + const os = require('os'); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3584-')); + t.after(() => cleanup(tmp)); + + fs.mkdirSync(path.join(tmp, '.planning'), { recursive: true }); + fs.writeFileSync( + path.join(tmp, '.planning', 'config.json'), + JSON.stringify({ runtime: 'codex-cli' }), + ); + + const saved = process.env.GSD_RUNTIME; + try { + delete process.env.GSD_RUNTIME; + assert.strictEqual(resolveRuntime(tmp), 'codex'); + } finally { + if (saved !== undefined) process.env.GSD_RUNTIME = saved; + } + }); + + test('canonicalizes non-codex config aliases', (t) => { + const fs = require('fs'); + const os = require('os'); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3584-')); + t.after(() => cleanup(tmp)); + + fs.mkdirSync(path.join(tmp, '.planning'), { recursive: true }); + fs.writeFileSync( + path.join(tmp, '.planning', 'config.json'), + JSON.stringify({ runtime: 'claude-code' }), + ); + + const saved = process.env.GSD_RUNTIME; + try { + delete process.env.GSD_RUNTIME; + assert.strictEqual(resolveRuntime(tmp), 'claude'); + } finally { + if (saved !== undefined) process.env.GSD_RUNTIME = saved; + } + }); + + test('lowercases the resolved runtime', () => { + const saved = process.env.GSD_RUNTIME; + try { + process.env.GSD_RUNTIME = 'CLAUDE'; + assert.strictEqual(resolveRuntime(null), 'claude'); + } finally { + if (saved === undefined) delete process.env.GSD_RUNTIME; + else process.env.GSD_RUNTIME = saved; + } + }); +}); + }); +} diff --git a/tests/health-validation.test.cjs b/tests/health-validation.test.cjs index 19fdacdfe..957d3c7d9 100644 --- a/tests/health-validation.test.cjs +++ b/tests/health-validation.test.cjs @@ -924,3 +924,733 @@ describe('bug #416 case 3: STATE.md v5.0 with matching v5.0-phases/ → returns }); }); } + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/26-w005-w006-i001-cjs-drift-regression.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:26-w005-w006-i001-cjs-drift-regression (consolidation epic #1969 B3 #1972)", () => { +'use strict'; + +/** + * Regression tests for issue #26 (open-gsd/gsd-core). + * Three generator-pattern drift items: W005 phaseDirNameRe, + * W006-archived regex constants (PHASE_TOKEN_FROM_DIR_RE, MILESTONE_ARCHIVE_DIR_RE), + * I001 canonicalPlanStem. + * + * After the generator migration, all three helpers are sourced from + * validate.cjs. If they diverge from validate.ts, these + * tests go RED. + * + * References: + * - Issue #26 (open-gsd/gsd-core) — three drift items + reproducer + * - ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md) + * - PR #154 (issue #4) — generator pattern precedent + * - PR #156 (issue #6) — validate.ts generator scaffolding (#26 extends this) + * - Original PR #3479 — first validate.ts false-positive fix (never reached CJS) + */ + +const { describe, test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); + +const { runGsdTools, cleanup } = require('./helpers.cjs'); + +function mkplanning(base) { + const planningDir = path.join(base, '.planning'); + const phasesDir = path.join(planningDir, 'phases'); + fs.mkdirSync(phasesDir, { recursive: true }); + return { planningDir, phasesDir }; +} + +function writeProjectMd(planningDir) { + fs.writeFileSync( + path.join(planningDir, 'PROJECT.md'), + '# Project\n\n## What This Is\nTest.\n\n## Core Value\nTest.\n\n## Requirements\nTest.\n', + ); +} + +function writeStateMd(planningDir, phase) { + fs.writeFileSync( + path.join(planningDir, 'STATE.md'), + `# State\n\n**Current Phase:** ${phase}\n**Status:** In progress\n`, + ); +} + +function writeConfigJson(planningDir) { + fs.writeFileSync( + path.join(planningDir, 'config.json'), + JSON.stringify({ model_profile: 'balanced' }), + ); +} + +// ── Drift Item W005: phaseDirNameRe ────────────────────────────────────────── +// +// Issue #26 reproducer (verbatim): +// mkdir -p .planning/phases/999.1-foo +// echo "# Roadmap" > .planning/ROADMAP.md +// node .claude/gsd-core/bin/gsd-tools.cjs validate health +// # Bug: emits W005 about 999.1-foo not following NN-name format +// +// verify.cjs must consume phaseDirNameRe from validate.cjs so +// the regex /^\d{2,}(?:\.\d+)*-[\w-]+$/ is the single source of truth. + +describe('Drift item W005 — phaseDirNameRe: 999.X-name dirs must not trigger W005', () => { + let tmpDir; + + before(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-26-d1-')); + const { planningDir, phasesDir } = mkplanning(tmpDir); + writeProjectMd(planningDir); + writeStateMd(planningDir, '999.1'); + writeConfigJson(planningDir); + + fs.writeFileSync( + path.join(planningDir, 'ROADMAP.md'), + '# Roadmap\n\n- [x] **Phase 999.1:** Long Phase\n\n### Phase 999.1: Long Phase\n', + ); + + // Exact reproducer from issue #26 + fs.mkdirSync(path.join(phasesDir, '999.1-foo'), { recursive: true }); + }); + + after(() => { cleanup(tmpDir); }); + + test('no W005 for 999.1-foo (multi-digit sub-phase prefix)', () => { + const result = runGsdTools(['validate', 'health', '--json'], tmpDir); + assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`); + const data = JSON.parse(result.output); + const w005 = (data.warnings ?? []).filter((w) => w.code === 'W005'); + assert.strictEqual(w005.length, 0, + `Expected zero W005 for 999.1-foo, got: ${JSON.stringify(w005)}`); + }); + + test('phaseDirNameRe is exported from validate.cjs', () => { + const gen = require('../gsd-core/bin/lib/validate.cjs'); + assert.ok(gen.phaseDirNameRe instanceof RegExp, + 'validate.cjs must export phaseDirNameRe as a RegExp'); + const re = gen.phaseDirNameRe; + assert.ok(re.test('01-setup'), 'should accept 01-setup'); + assert.ok(re.test('999-longphase'), 'should accept 999-longphase (3-digit prefix)'); + assert.ok(re.test('999.1-foo'), 'should accept 999.1-foo (sub-phase)'); + assert.ok(re.test('MANIFOLD-999.1-foo'), 'should accept long project-code prefixes'); + assert.ok(re.test('APP1-999.1-foo'), 'should accept numeric characters in project-code prefixes'); + assert.ok(re.test('APP_1-999.1-foo'), 'should accept underscore characters in project-code prefixes'); + assert.ok(!re.test('1-shortname'), 'should reject single-digit prefix'); + }); +}); + +// ── Drift Item W006-archived: PHASE_TOKEN_FROM_DIR_RE / MILESTONE_ARCHIVE_DIR_RE ─ +// +// forEachArchivedPhaseToken() in verify.cjs uses two inline regex constants. +// After migration both are sourced from validate.cjs: +// PHASE_TOKEN_FROM_DIR_RE — extracts token from dir name like "64-auth-service" +// MILESTONE_ARCHIVE_DIR_RE — matches archive dirs like "v1.0-phases" +// +// Test: phase 64 was archived to milestones/v1.0-phases/64-auth-service/. +// Without correct archive detection, W006 fires for "Phase 64 in ROADMAP.md +// but no directory on disk". + +describe('Drift item W006-archived — MILESTONE_ARCHIVE_DIR_RE and PHASE_TOKEN_FROM_DIR_RE', () => { + let tmpDir; + + before(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-26-d2-')); + const { planningDir, phasesDir } = mkplanning(tmpDir); + writeProjectMd(planningDir); + writeStateMd(planningDir, '65'); + writeConfigJson(planningDir); + + fs.writeFileSync( + path.join(planningDir, 'ROADMAP.md'), + [ + '# Roadmap', + '', + '- [x] **Phase 65:** Current Work', + '', + '### Phase 65: Current Work', + '', + '
', + 'Milestone v1.0 — Shipped', + '', + '### Phase 64: Auth Service', + '', + '
', + '', + ].join('\n'), + ); + + // Active phasesDir: only phase 65 + fs.mkdirSync(path.join(phasesDir, '65-current-work'), { recursive: true }); + + // Archive: milestones/v1.0-phases/64-auth-service + // MILESTONE_ARCHIVE_DIR_RE must match "v1.0-phases" + // PHASE_TOKEN_FROM_DIR_RE must extract "64" from "64-auth-service" + fs.mkdirSync( + path.join(planningDir, 'milestones', 'v1.0-phases', '64-auth-service'), + { recursive: true }, + ); + }); + + after(() => { cleanup(tmpDir); }); + + test('no W006 for Phase 64 archived under milestones/v1.0-phases/', () => { + const result = runGsdTools(['validate', 'health', '--json'], tmpDir); + assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`); + const data = JSON.parse(result.output); + const w006 = (data.warnings ?? []).filter( + (w) => w.code === 'W006' && /Phase 64/i.test(w.message), + ); + assert.strictEqual(w006.length, 0, + `Expected no W006 for archived Phase 64, got: ${JSON.stringify(w006)}`); + }); + + test('MILESTONE_ARCHIVE_DIR_RE is exported and matches vN.N-phases dirs', () => { + const gen = require('../gsd-core/bin/lib/validate.cjs'); + assert.ok(gen.MILESTONE_ARCHIVE_DIR_RE instanceof RegExp, + 'validate.cjs must export MILESTONE_ARCHIVE_DIR_RE'); + const re = gen.MILESTONE_ARCHIVE_DIR_RE; + assert.ok(re.test('v1.0-phases'), 'should match v1.0-phases'); + assert.ok(re.test('v1.10-phases'), 'should match v1.10-phases'); + assert.ok(!re.test('phases'), 'should NOT match plain phases'); + assert.ok(!re.test('1.0-phases'), 'should NOT match missing v prefix'); + }); + + test('PHASE_TOKEN_FROM_DIR_RE is exported and extracts phase tokens correctly', () => { + const gen = require('../gsd-core/bin/lib/validate.cjs'); + assert.ok(gen.PHASE_TOKEN_FROM_DIR_RE instanceof RegExp, + 'validate.cjs must export PHASE_TOKEN_FROM_DIR_RE'); + const re = gen.PHASE_TOKEN_FROM_DIR_RE; + assert.strictEqual(re.exec('64-auth-service')?.[1], '64'); + assert.strictEqual(re.exec('03B-feature')?.[1], '03B'); + assert.strictEqual(re.exec('999.1-foo')?.[1], '999.1'); + assert.strictEqual(re.exec('CK-64-auth')?.[1], '64'); + assert.strictEqual(re.exec('MANIFOLD-64-auth')?.[1], '64'); + assert.strictEqual(re.exec('APP1-64-auth')?.[1], '64'); + assert.strictEqual(re.exec('APP_1-64-auth')?.[1], '64'); + }); +}); + +// ── Drift Item I001: canonicalPlanStem ──────────────────────────────────────── +// +// validate.ts Check 7: canonicalPlanStem('68-01-scaffolding') → '68-01' +// verify.cjs had an inline copy. After migration, canonicalPlanStem is +// sourced from validate.cjs. +// +// Test: "68-01-scaffolding-PLAN.md" + "68-01-SUMMARY.md" → no I001 +// Both stems canonicalize to "68-01" → match found → I001 suppressed. + +describe('Drift item I001 — canonicalPlanStem: long PLAN stem matches short SUMMARY stem', () => { + let tmpDir; + + before(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-26-d3-')); + const { planningDir, phasesDir } = mkplanning(tmpDir); + writeProjectMd(planningDir); + writeStateMd(planningDir, '68'); + writeConfigJson(planningDir); + + fs.writeFileSync( + path.join(planningDir, 'ROADMAP.md'), + '# Roadmap\n\n- [x] **Phase 68:** Scaffolding\n\n### Phase 68: Scaffolding\n', + ); + + const phaseDir = path.join(phasesDir, '68-scaffolding'); + fs.mkdirSync(phaseDir, { recursive: true }); + + // Long-stem PLAN + short-stem SUMMARY → must match via canonicalPlanStem + fs.writeFileSync(path.join(phaseDir, '68-01-scaffolding-PLAN.md'), '---\nwave: 1\n---\n# Plan\n'); + fs.writeFileSync(path.join(phaseDir, '68-01-SUMMARY.md'), '# Summary\n'); + }); + + after(() => { cleanup(tmpDir); }); + + test('no I001 when 68-01-scaffolding-PLAN.md matches 68-01-SUMMARY.md via canonicalPlanStem', () => { + const result = runGsdTools(['validate', 'health', '--json'], tmpDir); + assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`); + const data = JSON.parse(result.output); + const i001 = (data.info ?? []).filter((i) => i.code === 'I001'); + assert.strictEqual(i001.length, 0, + `Expected zero I001, got: ${JSON.stringify(i001)}`); + }); + + test('canonicalPlanStem is exported from validate.cjs', () => { + const gen = require('../gsd-core/bin/lib/validate.cjs'); + assert.strictEqual(typeof gen.canonicalPlanStem, 'function', + 'validate.cjs must export canonicalPlanStem as a function'); + assert.strictEqual(gen.canonicalPlanStem('68-01-scaffolding'), '68-01'); + assert.strictEqual(gen.canonicalPlanStem('68-01'), '68-01'); + assert.strictEqual(gen.canonicalPlanStem('3A-01-feature'), '3A-01'); + assert.strictEqual(gen.canonicalPlanStem('no-match'), 'no-match'); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-663-redos-roadmap-phase-parsing.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-663-redos-roadmap-phase-parsing (consolidation epic #1969 B3 #1972)", () => { +/** + * Regression test for the ReDoS fixes in buildRoadmapPhaseVariants() and + * buildNotStartedPhaseVariants() (src/validate.cts, fix #663). + * + * The old patterns used nested quantifiers that caused catastrophic + * backtracking on crafted input: + * old: [\w][\w.-]*(?:-[\w.-]+)* ← ambiguous alternation → exponential + * new: [\w][\w.-]* ← single quantifier → linear + * + * The same nested-quantifier shape was fixed identically in src/verify.cts, + * src/commands.cts, and src/phase.cts. + * + * Part A: behavior preservation — the collapsed regex still matches the same + * phase identifiers as before on normal roadmap content. + * Part B: ReDoS adversarial fixtures — calls buildRoadmapPhaseVariants / + * buildNotStartedPhaseVariants with pathological input (a malformed heading + * or checklist line that has NO terminating colon). The adversarial input + * would cause catastrophic backtracking under the OLD nested-quantifier + * pattern; the fix makes backtracking linear. We assert on the STRUCTURED + * RESULT (the returned Set is empty — no match — because the colon is + * absent) rather than on elapsed time, in compliance with the + * local/no-elapsed-assertion ESLint rule. A { timeout: 5000 } backstop is + * retained so the test fails fast if a future regression reintroduces a + * slow pattern. + * + * Requirements: TEST-663-B + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const { + buildRoadmapPhaseVariants, + buildNotStartedPhaseVariants, +} = require('../gsd-core/bin/lib/validate.cjs'); + +// ─── Part A: behavior preservation ─────────────────────────────────────────── + +describe('buildRoadmapPhaseVariants — behavior preservation (#663)', () => { + test('matches plain numeric heading (Phase 1:)', () => { + const content = [ + '# Roadmap', + '## Phase 1: Foo', + ].join('\n'); + + const { roadmapPhases } = buildRoadmapPhaseVariants(content); + assert.ok(roadmapPhases.has('1'), 'roadmapPhases should contain "1"'); + }); + + test('matches milestone-prefixed heading (Phase 2-01:)', () => { + const content = [ + '# Roadmap', + '### Phase 2-01: Bar', + ].join('\n'); + + const { roadmapPhases } = buildRoadmapPhaseVariants(content); + assert.ok(roadmapPhases.has('2-01'), 'roadmapPhases should contain "2-01"'); + }); + + test('matches bracket-prefixed heading ([GSD] Phase 3.2:)', () => { + const content = [ + '# Roadmap', + '### [GSD] Phase 3.2: Baz', + ].join('\n'); + + const { roadmapPhases } = buildRoadmapPhaseVariants(content); + assert.ok(roadmapPhases.has('3.2'), 'roadmapPhases should contain "3.2"'); + }); + + test('collects all phase identifiers from mixed-format roadmap', () => { + const content = [ + '# Roadmap', + '## Phase 1: Alpha', + '### Phase 2-01: Beta', + '### [GSD] Phase 3.2: Gamma', + ].join('\n'); + + const { roadmapPhases } = buildRoadmapPhaseVariants(content); + assert.ok(roadmapPhases.has('1'), 'should have phase 1'); + assert.ok(roadmapPhases.has('2-01'), 'should have phase 2-01'); + assert.ok(roadmapPhases.has('3.2'), 'should have phase 3.2'); + }); + + test('populates roadmapPhaseVariants with padding-normalized forms', () => { + const content = [ + '# Roadmap', + '### Phase 2-01: Beta', + ].join('\n'); + + const { roadmapPhaseVariants } = buildRoadmapPhaseVariants(content); + // phaseVariants() adds both padded and unpadded forms + assert.ok(roadmapPhaseVariants.has('2-01') || roadmapPhaseVariants.has('02-01'), + 'roadmapPhaseVariants should contain at least one padding form of 2-01'); + }); +}); + +describe('buildNotStartedPhaseVariants — behavior preservation (#663)', () => { + test('matches unchecked checklist item (- [ ] Phase 4-01:)', () => { + const content = [ + '# Roadmap', + '- [ ] Phase 4-01: Qux', + ].join('\n'); + + const notStarted = buildNotStartedPhaseVariants(content); + // phaseVariants() expands 4-01 into multiple forms; at minimum the raw form is present. + assert.ok(notStarted.has('4-01') || notStarted.has('04-01'), + 'notStarted should contain a variant of 4-01'); + }); + + test('does not pick up checked items', () => { + const content = [ + '# Roadmap', + '- [x] Phase 5: Done', + ].join('\n'); + + const notStarted = buildNotStartedPhaseVariants(content); + assert.strictEqual(notStarted.has('5'), false, 'completed phase should not be in notStarted'); + }); +}); + +// ─── Part B: ReDoS adversarial fixtures ────────────────────────────────────── + +describe('buildRoadmapPhaseVariants — ReDoS adversarial fixture (#663)', () => { + // Pathological input: a heading line where the phase-id segment consists of + // many consecutive "-a" chunks with NO terminating colon. Under the old + // nested-quantifier pattern ([\w.-]*(?:-[\w.-]+)*\s*:) the engine must + // explore exponentially many ways to partition the "-a" repetitions before + // concluding there is no match. The fixed single-quantifier pattern + // ([\w.-]*\s*:) backtracks linearly. We assert that the malformed heading + // yields NO match (empty roadmapPhases Set) — the correct behavior when the + // terminating colon is absent. The { timeout: 5000 } backstop catches any + // regression that re-introduces a slow pattern. + test('malformed heading without colon yields no phase match (adversarial input)', { timeout: 5000 }, () => { + const pathological = '## Phase a' + '-a'.repeat(32) + ' '; + const { roadmapPhases } = buildRoadmapPhaseVariants(pathological); + assert.strictEqual(roadmapPhases.size, 0, + 'a heading with no terminating colon should not match any phase'); + }); +}); + +describe('buildNotStartedPhaseVariants — ReDoS adversarial fixture (#663)', () => { + // Same analysis: the old uncheckedPattern used the same nested quantifier. + // A checklist-style line with many "-a" segments and no colon triggers the + // same catastrophic backtracking. Assert the correct structured result: + // the malformed line yields an empty notStarted Set. + test('malformed unchecked-item without terminator yields no phase match (adversarial input)', { timeout: 5000 }, () => { + // No trailing colon or whitespace: the regex terminator [:\s*] cannot match, + // so the engine must backtrack through all '-a' repetitions and conclude no + // match. Under the old nested-quantifier pattern this was exponential; + // under the fixed linear pattern it returns immediately with an empty Set. + const pathological = '- [ ] Phase a' + '-a'.repeat(32); + const notStarted = buildNotStartedPhaseVariants(pathological); + assert.strictEqual(notStarted.size, 0, + 'an unchecked-item line with no terminating colon or space should not match any phase'); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-892-validate-checklist-roadmap-phases.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-892-validate-checklist-roadmap-phases (consolidation epic #1969 B3 #1972)", () => { +/** + * Regression test for #892: checklist-style roadmap phases (`- [x] **Phase NN: name**`) + * were silently skipped by `buildRoadmapPhaseVariants()`, causing W007 false positives + * on every on-disk phase dir when the project uses the checklist ROADMAP format. + * + * Covers: + * A. Unit-level: `buildRoadmapPhaseVariants()` in validate.cts recognises both + * checked (`- [x]`) and unchecked (`- [ ]`) checklist items. + * B. Integration: `validate health` emits NO W007 for a checklist-only roadmap + * whose phase dirs all appear in the checklist. + * C. Integration: `validate consistency` emits NO "exists on disk but not in + * ROADMAP.md" warning for the same checklist-only roadmap. + * + * Requirements: BUG-892 + */ +'use strict'; + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); +const { + buildRoadmapPhaseVariants, +} = require('../gsd-core/bin/lib/validate.cjs'); + +// ─── A: Unit-level ──────────────────────────────────────────────────────────── + +describe('buildRoadmapPhaseVariants — checklist format support (#892)', () => { + test('matches checked checklist item `- [x] **Phase 01: name**`', () => { + const content = [ + '# Roadmap', + '', + '- [x] **Phase 01: infrastructure-hardening**', + ].join('\n'); + + const { roadmapPhases } = buildRoadmapPhaseVariants(content); + assert.ok( + roadmapPhases.has('01') || roadmapPhases.has('1'), + `roadmapPhases should contain a variant of "01", got: ${JSON.stringify([...roadmapPhases])}` + ); + }); + + test('matches checked checklist item with uppercase X `- [X] **Phase 02: foo**`', () => { + const content = [ + '# Roadmap', + '', + '- [X] **Phase 02: feature-work**', + ].join('\n'); + + const { roadmapPhases } = buildRoadmapPhaseVariants(content); + assert.ok( + roadmapPhases.has('02') || roadmapPhases.has('2'), + `roadmapPhases should contain a variant of "02", got: ${JSON.stringify([...roadmapPhases])}` + ); + }); + + test('matches unchecked checklist item `- [ ] **Phase 03: name**`', () => { + // unchecked items are also phases — they just have not been started + const content = [ + '# Roadmap', + '', + '- [ ] **Phase 03: future-work**', + ].join('\n'); + + const { roadmapPhases } = buildRoadmapPhaseVariants(content); + assert.ok( + roadmapPhases.has('03') || roadmapPhases.has('3'), + `roadmapPhases should contain a variant of "03", got: ${JSON.stringify([...roadmapPhases])}` + ); + }); + + test('collects all phases from a pure checklist roadmap (no ## headings)', () => { + const content = [ + '# Roadmap', + '', + '- [x] **Phase 01: alpha**', + '- [x] **Phase 02: beta**', + '- [ ] **Phase 03: gamma**', + ].join('\n'); + + const { roadmapPhases } = buildRoadmapPhaseVariants(content); + const has = (id) => roadmapPhases.has(id) || roadmapPhases.has(id.replace(/^0+/, '')) || roadmapPhases.has(String(parseInt(id, 10)).padStart(2, '0')); + assert.ok(has('01'), 'should contain phase 01'); + assert.ok(has('02'), 'should contain phase 02'); + assert.ok(has('03'), 'should contain phase 03'); + }); + + test('populates roadmapPhaseVariants with padding-normalised forms for checklist phases', () => { + const content = [ + '# Roadmap', + '', + '- [x] **Phase 01: something**', + ].join('\n'); + + const { roadmapPhaseVariants } = buildRoadmapPhaseVariants(content); + // phaseVariants() adds both '1' and '01' forms + assert.ok( + roadmapPhaseVariants.has('1') || roadmapPhaseVariants.has('01'), + `roadmapPhaseVariants should contain at least one padding form, got: ${JSON.stringify([...roadmapPhaseVariants])}` + ); + }); + + test('mixed roadmap (headings + checklist) collects phases from both styles', () => { + const content = [ + '# Roadmap', + '', + '## Phase 1: heading-style', + '', + '- [x] **Phase 02: checklist-style**', + ].join('\n'); + + const { roadmapPhases } = buildRoadmapPhaseVariants(content); + assert.ok( + roadmapPhases.has('1') || roadmapPhases.has('01'), + 'should contain heading-style phase 1' + ); + assert.ok( + roadmapPhases.has('02') || roadmapPhases.has('2'), + 'should contain checklist-style phase 02' + ); + }); +}); + +// ─── B: validate health — no W007 for checklist-only roadmaps ──────────────── + +describe('validate health — checklist-style roadmap phases must not emit W007 (#892)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('no W007 when phase dirs match checked checklist entries in ROADMAP.md', () => { + // Write PROJECT.md, STATE.md, config.json, and a checklist-only ROADMAP.md + fs.writeFileSync( + path.join(tmpDir, '.planning', 'PROJECT.md'), + '# Project\n\n## What This Is\n\nTest.\n\n## Core Value\n\nValue.\n\n## Requirements\n\nRequirements.\n' + ); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + '# Session State\n\n## Current Position\n\nPhase 1 in progress.\n' + ); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ model_profile: 'balanced', commit_docs: true }, null, 2) + ); + + // Checklist-only ROADMAP: no ## Phase headings, only checklist items + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + [ + '# Roadmap', + '', + '- [x] **Phase 01: infrastructure-hardening**', + '- [x] **Phase 02: feature-work**', + '', + ].join('\n') + ); + + // Create matching phase directories on disk + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-infrastructure-hardening'), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-feature-work'), { recursive: true }); + + const result = runGsdTools('validate health', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + const w007s = output.warnings.filter((w) => w.code === 'W007'); + assert.strictEqual( + w007s.length, + 0, + `W007 must not fire for phases whose dirs are listed in a checklist-style ROADMAP.md, got: ${JSON.stringify(w007s)}` + ); + }); + + test('W007 still fires when a phase dir is genuinely absent from a checklist roadmap', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'PROJECT.md'), + '# Project\n\n## What This Is\n\nTest.\n\n## Core Value\n\nValue.\n\n## Requirements\n\nRequirements.\n' + ); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + '# Session State\n\n## Current Position\n\nPhase 1 in progress.\n' + ); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ model_profile: 'balanced', commit_docs: true }, null, 2) + ); + + // ROADMAP only lists phase 01, not phase 99 + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + [ + '# Roadmap', + '', + '- [x] **Phase 01: known-phase**', + '', + ].join('\n') + ); + + // Phase 01 dir is present but also an orphan phase 99 that is NOT in ROADMAP + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-known-phase'), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '99-orphan'), { recursive: true }); + + const result = runGsdTools('validate health', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + const w007s = output.warnings.filter((w) => w.code === 'W007'); + assert.ok( + w007s.length > 0, + `W007 must still fire for a phase dir genuinely not listed in ROADMAP.md, got warnings: ${JSON.stringify(output.warnings)}` + ); + // Should only flag phase 99, not phase 01 + assert.ok( + w007s.some((w) => w.message.includes('99')), + `W007 should reference orphan phase 99, got: ${JSON.stringify(w007s)}` + ); + assert.ok( + !w007s.some((w) => w.message.includes('01') || w.message.includes('1')), + `W007 must NOT flag phase 01 which is in the checklist, got: ${JSON.stringify(w007s)}` + ); + }); +}); + +// ─── C: validate consistency — no false positive for checklist-only roadmaps ─ + +describe('validate consistency — checklist-style roadmap phases must not emit false warnings (#892)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('no "exists on disk but not in ROADMAP.md" warning for checklist-matched phases', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'PROJECT.md'), + '# Project\n\n## What This Is\n\nTest.\n\n## Core Value\n\nValue.\n\n## Requirements\n\nRequirements.\n' + ); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + '# Session State\n\n## Current Position\n\nPhase 1 in progress.\n' + ); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ model_profile: 'balanced', commit_docs: true }, null, 2) + ); + + // Checklist-only ROADMAP + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + [ + '# Roadmap', + '', + '- [x] **Phase 01: infrastructure-hardening**', + '- [x] **Phase 02: feature-work**', + '', + ].join('\n') + ); + + // Create matching phase directories + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-infrastructure-hardening'), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-feature-work'), { recursive: true }); + + const result = runGsdTools('validate consistency', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + // No "exists on disk but not in ROADMAP" warnings for checklist-listed phases + const diskNotInRoadmapWarnings = (output.warnings || []).filter( + (w) => typeof w === 'string' + ? w.includes('exists on disk but not in ROADMAP') + : (w.message || '').includes('exists on disk but not in ROADMAP') + ); + assert.strictEqual( + diskNotInRoadmapWarnings.length, + 0, + `No "exists on disk but not in ROADMAP.md" warnings should fire for checklist-listed phases, got: ${JSON.stringify(diskNotInRoadmapWarnings)}` + ); + }); +}); + }); +} diff --git a/tests/ingest-docs.test.cjs b/tests/ingest-docs.test.cjs index 9c54a8aad..40330ca27 100644 --- a/tests/ingest-docs.test.cjs +++ b/tests/ingest-docs.test.cjs @@ -312,3 +312,163 @@ describe('import command adopts shared conflict-engine', () => { assert.ok(wfContent.includes('[INFO]')); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2801-ingest-docs-handler.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2801-ingest-docs-handler (consolidation epic #1969 B3 #1972)", () => { +/** + * Regression test for bug #2801 + * + * `/gsd-ingest-docs` was broken because: + * 1. `workflows/ingest-docs.md` called `gsd-sdk query init.ingest-docs` but the + * installed binary is `gsd-tools` (not `gsd-sdk`). + * 2. `gsd-tools init` had no `ingest-docs` case in its dispatch switch. + * + * The fix: + * - Added `case 'ingest-docs'` to the `init` switch in `gsd-tools.cjs`. + * - Exported `cmdInitIngestDocs` from `init.cjs`. + * - Updated `workflows/ingest-docs.md` to call `gsd-tools init ingest-docs`. + * + * This test prevents regression of the dispatch omission. + */ + +'use strict'; + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const childProc = require('node:child_process'); +const { createTempProject, cleanup, TOOLS_PATH } = require('./helpers.cjs'); + +const REPO_ROOT = path.join(__dirname, '..'); +const WORKFLOW_FILE = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'ingest-docs.md'); + +function spawnGsdTools(args, projectDir) { + let stdout = ''; + let exitCode = 0; + try { + stdout = childProc.execFileSync( + process.execPath, + [TOOLS_PATH, ...args, '--cwd', projectDir], + { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, GSD_SESSION_KEY: '' }, + } + ); + } catch (err) { + exitCode = err.status ?? 1; + stdout = (err.stdout?.toString() ?? '') + (err.stderr?.toString() ?? ''); + } + return { exitCode, stdout }; +} + +describe('bug-2801: gsd-tools init ingest-docs handler exists', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject('gsd-test-2801-'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('init ingest-docs exits 0 (not "Unknown init workflow")', () => { + const { exitCode, stdout } = spawnGsdTools(['init', 'ingest-docs', '--raw'], tmpDir); + assert.strictEqual(exitCode, 0, `expected exit 0, got: ${stdout}`); + }); + + test('init ingest-docs returns JSON with project_exists field', () => { + const { exitCode, stdout } = spawnGsdTools(['init', 'ingest-docs', '--raw'], tmpDir); + assert.strictEqual(exitCode, 0); + let json; + try { json = JSON.parse(stdout.trim()); } catch { assert.fail(`non-JSON output: ${stdout}`); } + assert.ok(Object.prototype.hasOwnProperty.call(json, 'project_exists'), 'project_exists present'); + }); + + test('init ingest-docs returns JSON with planning_exists field', () => { + const { exitCode, stdout } = spawnGsdTools(['init', 'ingest-docs', '--raw'], tmpDir); + assert.strictEqual(exitCode, 0); + const json = JSON.parse(stdout.trim()); + assert.ok(Object.prototype.hasOwnProperty.call(json, 'planning_exists'), 'planning_exists present'); + }); + + test('init ingest-docs returns JSON with has_git field', () => { + const { exitCode, stdout } = spawnGsdTools(['init', 'ingest-docs', '--raw'], tmpDir); + assert.strictEqual(exitCode, 0); + const json = JSON.parse(stdout.trim()); + assert.ok(Object.prototype.hasOwnProperty.call(json, 'has_git'), 'has_git present'); + }); + + test('init ingest-docs returns JSON with project_path field', () => { + const { exitCode, stdout } = spawnGsdTools(['init', 'ingest-docs', '--raw'], tmpDir); + assert.strictEqual(exitCode, 0); + const json = JSON.parse(stdout.trim()); + assert.ok(Object.prototype.hasOwnProperty.call(json, 'project_path'), 'project_path present'); + assert.ok(Object.prototype.hasOwnProperty.call(json, 'commit_docs'), 'commit_docs present'); + }); + + test('planning_exists is true when .planning/ directory exists', () => { + const { exitCode, stdout } = spawnGsdTools(['init', 'ingest-docs', '--raw'], tmpDir); + assert.strictEqual(exitCode, 0); + const json = JSON.parse(stdout.trim()); + assert.strictEqual(json.planning_exists, true, 'planning_exists should be true (.planning/ created by createTempProject)'); + }); +}); + +describe('bug-2801: ingest-docs.md workflow calls gsd-tools not gsd-sdk', () => { + test('no bash code block in ingest-docs.md calls gsd-sdk', () => { + const content = fs.readFileSync(WORKFLOW_FILE, 'utf-8'); + // Extract bash fenced code blocks structurally. + const bashBlocks = []; + const codeBlockRe = /```bash\r?\n([\s\S]*?)```/g; + let m; + while ((m = codeBlockRe.exec(content)) !== null) { + bashBlocks.push(m[1]); + } + assert.ok(bashBlocks.length > 0, 'expected bash code blocks in workflow'); + + // Check every line in every bash block — not just lines that start with the token, + // since gsd-sdk can appear in subshell expansions like $(gsd-sdk query ...). + const sdkCalls = bashBlocks + .join('\n') + .split('\n') + .filter((line) => /\bgsd-sdk\b/.test(line)); + + assert.deepStrictEqual( + sdkCalls, + [], + `workflow bash blocks still reference gsd-sdk (should use gsd-tools): ${sdkCalls.join(', ')}` + ); + }); + + test('ingest-docs.md init step uses the gsd_run launcher (#637)', () => { + const content = fs.readFileSync(WORKFLOW_FILE, 'utf-8'); + // Parse fenced bash blocks structurally — do not match raw markdown text. + const codeBlockRe = /```bash\r?\n([\s\S]*?)```/g; + const bashLines = [...content.matchAll(codeBlockRe)] + .flatMap((m) => m[1].split('\n')) + .filter((l) => !/^\s*#/.test(l)); + // #637 routes ingest-docs through the resolved `gsd_run` launcher instead of + // the hardcoded `node "$HOME/.../gsd-tools.cjs"` path (which misses global + // installs). The legacy bare `gsd-tools` form remains the bug and is still + // rejected by bug-2851's repo-wide guard. + const initLine = bashLines.find((l) => + /\bgsd_run\s+init\s+ingest-docs\b/.test(l) + ); + assert.ok(initLine, 'workflow must invoke init ingest-docs via the gsd_run launcher (#637)'); + }); + + test('cmdInitIngestDocs is exported from init.cjs', () => { + const init = require(path.join(REPO_ROOT, 'gsd-core', 'bin', 'lib', 'init.cjs')); + assert.strictEqual(typeof init.cmdInitIngestDocs, 'function', 'cmdInitIngestDocs must be exported'); + }); +}); + }); +} diff --git a/tests/install-runtime-artifacts.test.cjs b/tests/install-runtime-artifacts.test.cjs index a7882e9f6..5c6e25069 100644 --- a/tests/install-runtime-artifacts.test.cjs +++ b/tests/install-runtime-artifacts.test.cjs @@ -4493,3 +4493,58 @@ describe('single-owner reference-identity guard (ADR-1508 / #1511 Phase 2)', () }); }); } + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-190-bridge-collapse.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-190-bridge-collapse (consolidation epic #1969 B3 #1972)", () => { +'use strict'; + +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, '..'); + +function read(rel) { + return fs.readFileSync(path.join(ROOT, rel), 'utf8'); +} + +test('bridge collapse removes cjs-sdk-bridge and runtime-bridge-sync seam', () => { + const bridgePath = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'cjs-sdk-bridge.cjs'); + const sdkDir = path.join(ROOT, 'sdk'); + + assert.equal(fs.existsSync(bridgePath), false, 'cjs-sdk-bridge.cjs must be removed'); + assert.equal(fs.existsSync(sdkDir), false, 'sdk directory must be removed'); + + const routers = [ + 'gsd-core/bin/lib/init-command-router.cjs', + 'gsd-core/bin/lib/roadmap-command-router.cjs', + 'gsd-core/bin/lib/state-command-router.cjs', + 'gsd-core/bin/lib/validate-command-router.cjs', + 'gsd-core/bin/lib/verify-command-router.cjs', + 'gsd-core/bin/lib/phases-command-router.cjs', + ]; + + for (const rel of routers) { + const src = read(rel); + assert.equal( + src.includes('cjs-sdk-bridge.cjs'), + false, + `${rel} must not import cjs-sdk-bridge.cjs`, + ); + } + + const rootPkg = JSON.parse(read('package.json')); + assert.equal( + Object.prototype.hasOwnProperty.call(rootPkg.dependencies || {}, 'synckit'), + false, + 'package.json must not include synckit', + ); +}); + }); +} diff --git a/tests/intel.test.cjs b/tests/intel.test.cjs index 235441afd..b51130a55 100644 --- a/tests/intel.test.cjs +++ b/tests/intel.test.cjs @@ -1144,3 +1144,122 @@ describe('#1000 regression: gsd-intel-updater emits canonical intel filenames', } }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3258-no-stale-gsd-intel-references.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3258-no-stale-gsd-intel-references (consolidation epic #1969 B3 #1972)", () => { +// allow-test-rule: source-text-is-the-product — workflow, reference, and docs .md files (see #3258) +// ARE what the runtime loads and what users read; asserting their text content +// tests the deployed skill surface contract, not implementation internals. + +'use strict'; + +// Regression tests for bug #3258. +// +// PR #2790 folded `/gsd-intel` into `/gsd-map-codebase --query`. After that +// consolidation, five prose occurrences in two source files continued to +// reference the retired `/gsd-intel` slash command. Users invoking the wizard +// were directed to a command that no longer exists. +// +// Fix: replace each `/gsd-intel` (the retired user-facing slash command) with +// `/gsd-map-codebase --query` in: +// - gsd-core/references/planning-config.md +// - gsd-core/workflows/settings.md +// - docs/INVENTORY.md +// - docs/USER-GUIDE.md +// - docs/FEATURES.md +// +// Allowed: `gsd-intel-updater` (still-valid agent name, no leading slash), +// `intel.cjs` / `intel.enabled` / `intel.*` (internal backend, not user command), +// CHANGELOG.md (historical record), test files themselves. +// +// This test distinguishes `/gsd-intel` (the retired slash command, leading slash) +// from `gsd-intel-updater` (still-valid agent) by grepping for the literal +// string `/gsd-intel` and then asserting no match survives after excluding +// the `-updater` suffix. + +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, '..'); + +/** Walk a directory recursively and return absolute paths of all .md files. */ +function walkMd(dir) { + const results = []; + if (!fs.existsSync(dir)) return results; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const abs = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...walkMd(abs)); + } else if (entry.isFile() && entry.name.endsWith('.md')) { + results.push(abs); + } + } + return results; +} + +/** + * Return all lines in `src` that contain `/gsd-intel` (the retired slash + * command) but are NOT the agent name `gsd-intel-updater`. + * We match the literal substring `/gsd-intel` (with leading slash) and then + * exclude any line where the match is immediately followed by `-updater`. + */ +function staleLinesIn(src) { + return src.split('\n').filter((line) => { + if (!line.includes('/gsd-intel')) return false; + // Remove all occurrences of the valid agent name; if nothing remains, skip. + const stripped = line.replace(/\/gsd-intel-updater/g, ''); + return stripped.includes('/gsd-intel'); + }); +} + +const SOURCE_DIRS = [ + path.join(ROOT, 'commands', 'gsd'), + path.join(ROOT, 'gsd-core', 'workflows'), + path.join(ROOT, 'gsd-core', 'references'), + path.join(ROOT, 'agents'), + path.join(ROOT, 'docs'), +]; + +describe('#3258: no stale /gsd-intel slash-command references in product source dirs', () => { + for (const dir of SOURCE_DIRS) { + const files = walkMd(dir); + for (const file of files) { + const rel = path.relative(ROOT, file); + + // Allowed exclusions: + // - CHANGELOG.md is a historical record; /gsd-intel appears in release notes + // - test files (under tests/) are excluded automatically by SOURCE_DIRS scope + if (rel === 'CHANGELOG.md') continue; + + test(`${rel} has no stale /gsd-intel references`, () => { + let src; + try { + src = fs.readFileSync(file, 'utf8'); + } catch (err) { + throw new Error(`failed reading ${rel}: ${err.message}`); + } + + const staleLines = staleLinesIn(src); + assert.strictEqual( + staleLines.length, + 0, + [ + `${rel} contains ${staleLines.length} stale /gsd-intel reference(s).`, + 'Replace with /gsd-map-codebase --query (retired by PR #2790).', + 'Stale lines:', + ...staleLines.map((l) => ` ${l.trim()}`), + ].join('\n'), + ); + }); + } + } +}); + }); +} diff --git a/tests/kimi-agent-converter.test.cjs b/tests/kimi-agent-converter.test.cjs index 46bb4751c..4b2d31d48 100644 --- a/tests/kimi-agent-converter.test.cjs +++ b/tests/kimi-agent-converter.test.cjs @@ -157,3 +157,436 @@ describe('buildKimiAgentArtifacts', () => { assert.ok(!result.root.yaml.includes('DefinitelyUnknownTool'), 'unsupported tool names are not emitted'); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-kimi-path-layout-local-guard.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-kimi-path-layout-local-guard (consolidation epic #1969 B3 #1972)", () => { +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const { cleanup } = require('./helpers.cjs'); +const { installerEnv } = require('./helpers/install-shared.cjs'); + +const ROOT = path.join(__dirname, '..'); + +// Cross-platform temp paths for test fixtures (avoids hardcoded /tmp) +const KIMI_CFG = path.join(os.tmpdir(), 'gsd-kimi-config-test').replace(/\\/g, '/'); +const XDG_HOME = path.join(os.tmpdir(), 'gsd-xdg-home-test'); +const INSTALL_SCRIPT = path.join(ROOT, 'bin', 'install.js'); + +const { + getGlobalConfigDir, + getGlobalSkillsBase, + getGlobalSkillDir, + resolveKimiGlobalDir, +} = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-homes.cjs')); +const { + resolveRuntimeArtifactLayout, +} = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-artifact-layout.cjs')); +const { + getGlobalDir, + getConfigDirFromHome, +} = require('../bin/install.js'); + +function withEnv(updates, fn) { + const saved = {}; + for (const key of Object.keys(updates)) { + saved[key] = process.env[key]; + const value = updates[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + try { + return fn(); + } finally { + for (const key of Object.keys(updates)) { + if (saved[key] === undefined) delete process.env[key]; + else process.env[key] = saved[key]; + } + } +} + +describe('Kimi runtime homes', () => { + test('default Kimi global root is recommended ~/.config/agents when no generic skills root exists', () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-home-default-')); + try { + withEnv({ KIMI_CONFIG_DIR: undefined, XDG_CONFIG_HOME: undefined, HOME: tmpHome, USERPROFILE: tmpHome }, () => { + assert.strictEqual( + getGlobalConfigDir('kimi'), + path.join(tmpHome, '.config', 'agents'), + ); + assert.strictEqual( + getGlobalSkillsBase('kimi'), + path.join(tmpHome, '.config', 'agents', 'skills'), + ); + assert.strictEqual( + getGlobalSkillDir('kimi', 'gsd-help'), + path.join(tmpHome, '.config', 'agents', 'skills', 'gsd-help'), + ); + assert.notStrictEqual( + getGlobalSkillsBase('kimi'), + path.join(tmpHome, '.kimi-code', 'skills'), + ); + }); + } finally { + cleanup(tmpHome); + } + }); + + test('Kimi root resolution follows first-existing generic skills directory order', () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-home-existing-')); + try { + const recommendedRoot = path.join(tmpHome, '.config', 'agents'); + const fallbackRoot = path.join(tmpHome, '.agents'); + assert.strictEqual( + resolveKimiGlobalDir({ env: {}, home: tmpHome, existsSync: fs.existsSync }), + recommendedRoot, + ); + + fs.mkdirSync(path.join(fallbackRoot, 'skills'), { recursive: true }); + assert.strictEqual( + resolveKimiGlobalDir({ env: {}, home: tmpHome, existsSync: fs.existsSync }), + fallbackRoot, + ); + + fs.mkdirSync(path.join(recommendedRoot, 'skills'), { recursive: true }); + assert.strictEqual( + resolveKimiGlobalDir({ env: {}, home: tmpHome, existsSync: fs.existsSync }), + recommendedRoot, + ); + } finally { + cleanup(tmpHome); + } + }); + + test('KIMI_CONFIG_DIR can select the brand-specific ~/.kimi-code root', () => { + const customKimiDir = path.join(os.tmpdir(), 'custom-kimi-code'); + withEnv({ KIMI_CONFIG_DIR: customKimiDir, XDG_CONFIG_HOME: undefined }, () => { + assert.strictEqual(String(getGlobalConfigDir('kimi')).replace(/\\/g, '/'), customKimiDir.replace(/\\/g, '/')); + assert.strictEqual( + getGlobalSkillsBase('kimi'), + path.join(customKimiDir, 'skills'), + ); + assert.strictEqual(String(getGlobalDir('kimi')).replace(/\\/g, '/'), customKimiDir.replace(/\\/g, '/')); + }); + }); + + test('XDG_CONFIG_HOME does not change Kimi default root', () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-home-xdg-')); + try { + withEnv({ KIMI_CONFIG_DIR: undefined, XDG_CONFIG_HOME: XDG_HOME, HOME: tmpHome, USERPROFILE: tmpHome }, () => { + assert.strictEqual( + getGlobalConfigDir('kimi'), + path.join(tmpHome, '.config', 'agents'), + ); + assert.strictEqual( + getConfigDirFromHome('kimi', true), + "'.config', 'agents'", + ); + }); + } finally { + cleanup(tmpHome); + } + }); + + test('Kimi global install reuses existing ~/.agents/skills when recommended root is absent', () => { + const tmpProject = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-existing-agents-project-')); + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-existing-agents-home-')); + try { + fs.mkdirSync(path.join(tmpHome, '.agents', 'skills'), { recursive: true }); + const result = spawnSync( + process.execPath, + [INSTALL_SCRIPT, '--kimi', '--global', '--no-sdk'], + { + cwd: tmpProject, + encoding: 'utf8', + env: installerEnv({ HOME: tmpHome, USERPROFILE: tmpHome }), + }, + ); + + assert.strictEqual( + result.status, + 0, + `expected --kimi --global to reuse existing ~/.agents/skills\nstdout: ${result.stdout}\nstderr: ${result.stderr}`, + ); + assert.strictEqual( + fs.existsSync(path.join(tmpHome, '.agents', 'skills', 'gsd-new-project', 'SKILL.md')), + true, + ); + assert.strictEqual( + fs.existsSync(path.join(tmpHome, '.config', 'agents', 'skills', 'gsd-new-project', 'SKILL.md')), + false, + ); + } finally { + cleanup(tmpProject); + cleanup(tmpHome); + } + }); +}); + +describe('Kimi runtime artifact layout', () => { + test('global layout stages Kimi skills and agents while local layout remains guarded', () => { + const globalLayout = resolveRuntimeArtifactLayout('kimi', KIMI_CFG, 'global'); + assert.strictEqual(globalLayout.runtime, 'kimi'); + assert.strictEqual(String(globalLayout.configDir).replace(/\\/g, '/'), KIMI_CFG); + assert.strictEqual(globalLayout.kinds.length, 2); + assert.strictEqual(globalLayout.kinds[0].kind, 'skills'); + assert.strictEqual(globalLayout.kinds[0].destSubpath, 'skills'); + assert.strictEqual(globalLayout.kinds[0].prefix, 'gsd-'); + assert.strictEqual(typeof globalLayout.kinds[0].stage, 'function'); + assert.strictEqual(globalLayout.kinds[1].kind, 'kimi-agents'); + assert.strictEqual(globalLayout.kinds[1].destSubpath, 'agents'); + assert.strictEqual(globalLayout.kinds[1].prefix, 'gsd'); + assert.strictEqual(typeof globalLayout.kinds[1].stage, 'function'); + + const localLayout = resolveRuntimeArtifactLayout('kimi', KIMI_CFG, 'local'); + assert.strictEqual(localLayout.runtime, 'kimi'); + assert.deepStrictEqual(localLayout.kinds, []); + }); +}); + +describe('Kimi local install guard', () => { + test('--kimi --local exits successfully without writing local Kimi project artifacts', () => { + const tmpProject = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-local-project-')); + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-local-home-')); + try { + const result = spawnSync( + process.execPath, + [INSTALL_SCRIPT, '--kimi', '--local', '--no-sdk'], + { + cwd: tmpProject, + encoding: 'utf8', + env: installerEnv({ HOME: tmpHome, USERPROFILE: tmpHome }), + }, + ); + + assert.strictEqual( + result.status, + 0, + `expected --kimi --local guard to no-op successfully\nstdout: ${result.stdout}\nstderr: ${result.stderr}`, + ); + const combined = `${result.stdout}\n${result.stderr}`; + assert.match(combined, /Kimi local install/i); + assert.match(combined, /deferred/i); + + assert.ok(!fs.existsSync(path.join(tmpProject, '.kimi')), 'must not create legacy .kimi/'); + assert.ok(!fs.existsSync(path.join(tmpProject, '.kimi-code')), 'must not create .kimi-code/'); + assert.ok(!fs.existsSync(path.join(tmpProject, '.agents')), 'must not create .agents/'); + assert.ok(!fs.existsSync(path.join(tmpProject, '.claude')), 'must not fall back to Claude local install'); + } finally { + cleanup(tmpProject); + cleanup(tmpHome); + } + }); + + test('--kimi --global writes converted Kimi skills and agent artifacts', () => { + const tmpProject = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-global-project-')); + const tmpConfig = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-global-config-')); + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-global-home-')); + try { + const result = spawnSync( + process.execPath, + [INSTALL_SCRIPT, '--kimi', '--global', '--config-dir', tmpConfig, '--no-sdk'], + { + cwd: tmpProject, + encoding: 'utf8', + env: installerEnv({ HOME: tmpHome, USERPROFILE: tmpHome }), + }, + ); + + assert.strictEqual( + result.status, + 0, + `expected --kimi --global to install Kimi skills successfully\nstdout: ${result.stdout}\nstderr: ${result.stderr}`, + ); + const combined = `${result.stdout}\n${result.stderr}`; + assert.match(combined, /Installing for .*Kimi/i); + assert.match(combined, /Installed \d+ Kimi skills to skills\//i); + assert.match(combined, /Generated Kimi root agent: .*agents.*gsd\.yaml/i); + assert.match(combined, /kimi --agent-file/i); + assert.match(combined, /Wrote file manifest/i); + + const skillFile = path.join(tmpConfig, 'skills', 'gsd-new-project', 'SKILL.md'); + assert.ok(fs.existsSync(skillFile), 'must write gsd-new-project/SKILL.md'); + const skillContent = fs.readFileSync(skillFile, 'utf8'); + assert.match(skillContent, /^name: gsd-new-project$/m); + assert.match(skillContent, /\/skill:gsd-new-project/); + assert.match(skillContent, /gsd-core\/workflows\/new-project\.md/); + assert.doesNotMatch(skillContent, /@~\/\.claude\/gsd-core|@\$HOME\/\.claude\/gsd-core/); + assert.doesNotMatch(skillContent, /@[^\r\n]*\\/, 'serialized Kimi payload references must use forward slashes'); + assert.doesNotMatch(skillContent, /kimi_cli\.tools|system_prompt_path|^version: 1$/m); + + const rootYaml = path.join(tmpConfig, 'agents', 'gsd.yaml'); + const rootPrompt = path.join(tmpConfig, 'agents', 'gsd.md'); + const executorYaml = path.join(tmpConfig, 'agents', 'subagents', 'gsd-executor.yaml'); + const executorPrompt = path.join(tmpConfig, 'agents', 'subagents', 'gsd-executor.md'); + assert.ok(fs.existsSync(rootYaml), 'must write agents/gsd.yaml'); + assert.ok(fs.existsSync(rootPrompt), 'must write agents/gsd.md'); + assert.ok(fs.existsSync(executorYaml), 'must write agents/subagents/gsd-executor.yaml'); + assert.ok(fs.existsSync(executorPrompt), 'must write agents/subagents/gsd-executor.md'); + + const rootYamlContent = fs.readFileSync(rootYaml, 'utf8'); + assert.match(rootYamlContent, /^version: 1$/m); + assert.match(rootYamlContent, /^agent:$/m); + assert.match(rootYamlContent, /extend: default/); + assert.match(rootYamlContent, /system_prompt_path: \.\/gsd\.md/); + assert.match(rootYamlContent, /tools:/); + assert.match(rootYamlContent, /subagents:/); + assert.match(rootYamlContent, /kimi_cli\.tools\./); + assert.doesNotMatch(rootYamlContent, /mcp__/); + + const executorYamlContent = fs.readFileSync(executorYaml, 'utf8'); + assert.match(executorYamlContent, /system_prompt_path: \.\/gsd-executor\.md/); + assert.match(executorYamlContent, /kimi_cli\.tools\./); + assert.doesNotMatch(executorYamlContent, /mcp__/); + + assert.ok(fs.existsSync(path.join(tmpConfig, 'gsd-core', 'workflows', 'new-project.md')), 'must write workflow payloads used by Kimi skills'); + const manifestPath = path.join(tmpConfig, 'gsd-file-manifest.json'); + assert.ok(fs.existsSync(manifestPath), 'must write gsd-file-manifest.json for Kimi installs'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + assert.ok(manifest.files['skills/gsd-new-project/SKILL.md'], 'manifest tracks generated Kimi skill'); + assert.ok(manifest.files['agents/gsd.yaml'], 'manifest tracks Kimi root agent YAML'); + assert.ok(manifest.files['agents/gsd.md'], 'manifest tracks Kimi root agent prompt'); + assert.ok(manifest.files['agents/subagents/gsd-executor.yaml'], 'manifest tracks Kimi subagent YAML'); + assert.ok(manifest.files['agents/subagents/gsd-executor.md'], 'manifest tracks Kimi subagent prompt'); + assert.ok(manifest.files['gsd-core/workflows/new-project.md'], 'manifest tracks installed workflow payload'); + assert.ok(!fs.existsSync(path.join(tmpConfig, 'hooks')), 'must not write hooks under the Kimi root'); + assert.ok(!fs.existsSync(path.join(tmpConfig, 'settings.json')), 'must not write settings.json under the Kimi root'); + assert.ok(!fs.existsSync(path.join(tmpConfig, '.clinerules')), 'must not write rules under the Kimi root'); + } finally { + cleanup(tmpProject); + cleanup(tmpConfig); + cleanup(tmpHome); + } + }); + + test('--kimi --global backs up local edits to generated skills and agent artifacts on reinstall', () => { + const tmpProject = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-reinstall-project-')); + const tmpConfig = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-reinstall-config-')); + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-reinstall-home-')); + const env = installerEnv({ HOME: tmpHome, USERPROFILE: tmpHome }); + + try { + const installArgs = [INSTALL_SCRIPT, '--kimi', '--global', '--config-dir', tmpConfig, '--no-sdk']; + const first = spawnSync(process.execPath, installArgs, { + cwd: tmpProject, + encoding: 'utf8', + env, + }); + assert.strictEqual( + first.status, + 0, + `first install failed\nstdout: ${first.stdout}\nstderr: ${first.stderr}`, + ); + + const skillFile = path.join(tmpConfig, 'skills', 'gsd-new-project', 'SKILL.md'); + const agentPrompt = path.join(tmpConfig, 'agents', 'subagents', 'gsd-executor.md'); + fs.appendFileSync(skillFile, '\nUSER LOCAL KIMI SKILL EDIT\n'); + fs.appendFileSync(agentPrompt, '\nUSER LOCAL KIMI AGENT EDIT\n'); + + const second = spawnSync(process.execPath, installArgs, { + cwd: tmpProject, + encoding: 'utf8', + env, + }); + assert.strictEqual( + second.status, + 0, + `second install failed\nstdout: ${second.stdout}\nstderr: ${second.stderr}`, + ); + const secondOutput = `${second.stdout}\n${second.stderr}`; + assert.match(secondOutput, /locally modified GSD file/i); + assert.match(secondOutput, /\/skill:gsd-update --reapply/); + assert.doesNotMatch(secondOutput, /Run\s+\/gsd-update --reapply/); + + const skillBackup = path.join(tmpConfig, 'gsd-local-patches', 'skills', 'gsd-new-project', 'SKILL.md'); + const agentBackup = path.join(tmpConfig, 'gsd-local-patches', 'agents', 'subagents', 'gsd-executor.md'); + assert.match(fs.readFileSync(skillBackup, 'utf8'), /USER LOCAL KIMI SKILL EDIT/); + assert.match(fs.readFileSync(agentBackup, 'utf8'), /USER LOCAL KIMI AGENT EDIT/); + + const meta = JSON.parse(fs.readFileSync(path.join(tmpConfig, 'gsd-local-patches', 'backup-meta.json'), 'utf8')); + assert.ok(meta.files.includes('skills/gsd-new-project/SKILL.md')); + assert.ok(meta.files.includes('agents/subagents/gsd-executor.md')); + } finally { + cleanup(tmpProject); + cleanup(tmpConfig); + cleanup(tmpHome); + } + }); + + test('--kimi --global --uninstall removes GSD artifacts and preserves non-GSD Kimi content', () => { + const tmpProject = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-uninstall-project-')); + const tmpConfig = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-uninstall-config-')); + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-uninstall-home-')); + const env = installerEnv({ HOME: tmpHome, USERPROFILE: tmpHome }); + + try { + const installArgs = [INSTALL_SCRIPT, '--kimi', '--global', '--config-dir', tmpConfig, '--no-sdk']; + const installResult = spawnSync(process.execPath, installArgs, { + cwd: tmpProject, + encoding: 'utf8', + env, + }); + assert.strictEqual( + installResult.status, + 0, + `install failed\nstdout: ${installResult.stdout}\nstderr: ${installResult.stderr}`, + ); + + const foreignSkill = path.join(tmpConfig, 'skills', 'user-custom-skill', 'SKILL.md'); + const foreignRootAgent = path.join(tmpConfig, 'agents', 'user-agent.yaml'); + const foreignSubagent = path.join(tmpConfig, 'agents', 'subagents', 'user-agent.yaml'); + fs.mkdirSync(path.dirname(foreignSkill), { recursive: true }); + fs.mkdirSync(path.dirname(foreignSubagent), { recursive: true }); + fs.writeFileSync(foreignSkill, '# user skill\n', 'utf8'); + fs.writeFileSync(foreignRootAgent, 'version: 1\n', 'utf8'); + fs.writeFileSync(foreignSubagent, 'version: 1\n', 'utf8'); + + const uninstallResult = spawnSync( + process.execPath, + [INSTALL_SCRIPT, '--kimi', '--global', '--config-dir', tmpConfig, '--uninstall'], + { + cwd: tmpProject, + encoding: 'utf8', + env, + }, + ); + assert.strictEqual( + uninstallResult.status, + 0, + `uninstall failed\nstdout: ${uninstallResult.stdout}\nstderr: ${uninstallResult.stderr}`, + ); + assert.match(`${uninstallResult.stdout}\n${uninstallResult.stderr}`, /Kimi CLI/); + + assert.ok(!fs.existsSync(path.join(tmpConfig, 'skills', 'gsd-new-project')), 'must remove generated Kimi skills'); + assert.ok(!fs.existsSync(path.join(tmpConfig, 'agents', 'gsd.yaml')), 'must remove Kimi root agent YAML'); + assert.ok(!fs.existsSync(path.join(tmpConfig, 'agents', 'gsd.md')), 'must remove Kimi root agent prompt'); + assert.ok(!fs.existsSync(path.join(tmpConfig, 'agents', 'subagents', 'gsd-executor.yaml')), 'must remove generated Kimi subagent YAML'); + assert.ok(!fs.existsSync(path.join(tmpConfig, 'agents', 'subagents', 'gsd-executor.md')), 'must remove generated Kimi subagent prompt'); + assert.ok(!fs.existsSync(path.join(tmpConfig, 'gsd-core')), 'must remove installed workflow payload'); + assert.ok(!fs.existsSync(path.join(tmpConfig, 'gsd-file-manifest.json')), 'must remove the manifest'); + + assert.ok(fs.existsSync(foreignSkill), 'must preserve non-GSD Kimi skills'); + assert.ok(fs.existsSync(foreignRootAgent), 'must preserve non-GSD root agents'); + assert.ok(fs.existsSync(foreignSubagent), 'must preserve non-GSD subagents'); + } finally { + cleanup(tmpProject); + cleanup(tmpConfig); + cleanup(tmpHome); + } + }); +}); + }); +} diff --git a/tests/milestone.test.cjs b/tests/milestone.test.cjs index dcc01014c..880181235 100644 --- a/tests/milestone.test.cjs +++ b/tests/milestone.test.cjs @@ -901,3 +901,94 @@ describe('bug-978: milestone complete --force overrides unstarted-phase guard', }); }); } + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2660-one-liner-extraction.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2660-one-liner-extraction (consolidation epic #1969 B3 #1972)", () => { +/** + * Bug #2660: `gsd-tools milestone complete ` writes MILESTONES.md + * bullets that read "- One-liner:" (the literal label) instead of the prose + * after the label. + * + * Root cause: extractOneLinerFromBody() matches the first **...** span. In + * `**One-liner:** prose`, the first span contains only `One-liner:` so the + * function returns the label instead of the prose after it. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('path'); + +const { extractOneLinerFromBody } = require( + path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'core-utils.cjs') +); + +describe('bug #2660: extractOneLinerFromBody', () => { + test('a) body-style **One-liner:** label returns prose after the label', () => { + const content = + '# Phase 2 Plan 01: Foundation Summary\n\n**One-liner:** Real prose here.\n'; + assert.strictEqual(extractOneLinerFromBody(content), 'Real prose here.'); + }); + + test('b) frontmatter-only one-liner returns null (caller handles frontmatter)', () => { + const content = + '---\none-liner: Set up project\n---\n\n# Phase 1: Foundation Summary\n\nBody prose with no bold line.\n'; + assert.strictEqual(extractOneLinerFromBody(content), null); + }); + + test('c) no one-liner at all returns null', () => { + const content = + '# Phase 1: Foundation Summary\n\nJust some narrative, no bold line.\n'; + assert.strictEqual(extractOneLinerFromBody(content), null); + }); + + test('d) bold spans inside the prose are preserved', () => { + const content = + '# Phase 1: Foundation Summary\n\n**One-liner:** This is **important** stuff.\n'; + assert.strictEqual( + extractOneLinerFromBody(content), + 'This is **important** stuff.' + ); + }); + + test('e) empty prose after label returns null (no bogus bullet)', () => { + const empty = + '# Phase 1: Foundation Summary\n\n**One-liner:**\n\nRest of body.\n'; + const whitespace = + '# Phase 1: Foundation Summary\n\n**One-liner:** \n\nRest of body.\n'; + assert.strictEqual(extractOneLinerFromBody(empty), null); + assert.strictEqual(extractOneLinerFromBody(whitespace), null); + }); + + test('f) legacy bare **prose** format still works (no label, no colon)', () => { + // Preserve pre-existing behavior: SUMMARY files historically used + // `**bold prose**` with no label. See tests/commands.test.cjs:366 and + // tests/milestone.test.cjs:451 — both assert this form. + const content = + '---\nphase: "01"\n---\n\n# Phase 1: Foundation Summary\n\n**JWT auth with refresh rotation using jose library**\n\n## Performance\n'; + assert.strictEqual( + extractOneLinerFromBody(content), + 'JWT auth with refresh rotation using jose library' + ); + }); + + test('g) other **Label:** prefixes (e.g. Summary:) also capture prose after label', () => { + const content = + '# Phase 1: Foundation Summary\n\n**Summary:** Built the thing.\n'; + assert.strictEqual(extractOneLinerFromBody(content), 'Built the thing.'); + }); + + test('h) CRLF line endings (Windows) are handled', () => { + const content = + '---\r\nphase: "01"\r\n---\r\n\r\n# Phase 1: Foundation Summary\r\n\r\n**One-liner:** Windows-authored prose.\r\n'; + assert.strictEqual( + extractOneLinerFromBody(content), + 'Windows-authored prose.' + ); + }); +}); + }); +} diff --git a/tests/model-profiles.test.cjs b/tests/model-profiles.test.cjs index 9094f9bc8..e1bd22cc4 100644 --- a/tests/model-profiles.test.cjs +++ b/tests/model-profiles.test.cjs @@ -218,3 +218,537 @@ describe('formatAgentToModelMapAsTable', () => { assert.ok(table.includes('Agent'), 'should still have header'); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-384-agents-runtime-aware.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-384-agents-runtime-aware (consolidation epic #1969 B3 #1972)", () => { +/** + * Regression test for bug #384 — getAgentsDir() is runtime-blind. + * + * Before the fix, getAgentsDir() always resolved to the Claude path + * (~/.claude/agents) regardless of the active runtime, so on an OpenCode + * install checkAgentsInstalled() always returned agents_installed=false and + * agent_runtime was not surfaced at all. + * + * After the fix: + * - GSD_RUNTIME=opencode + OPENCODE_CONFIG_DIR pointing at a temp dir → + * agents_installed=true, agent_runtime='opencode', agents_dir under the + * opencode config dir + * - No GSD_RUNTIME + GSD_AGENTS_DIR pointing at a temp dir → + * agents_installed=true, agent_runtime='claude' + * - GSD_RUNTIME=opencode but agents dir empty → + * agents_installed=false, agent_runtime='opencode' + */ + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); + +const MODEL_PROFILES = require('../gsd-core/bin/lib/model-profiles.cjs').MODEL_PROFILES; +const EXPECTED_AGENTS = Object.keys(MODEL_PROFILES); + +/** + * Create an agents directory under configDir/agents and populate it with + * the expected agent .md files. + */ +function createAgentsInConfigDir(configDir) { + const agentsDir = path.join(configDir, 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + for (const name of EXPECTED_AGENTS) { + fs.writeFileSync( + path.join(agentsDir, `${name}.md`), + `---\nname: ${name}\ndescription: Test agent\ntools: Read, Bash\ncolor: cyan\n---\nAgent content.\n` + ); + } + return agentsDir; +} + +describe('bug #384 — getAgentsDir() is runtime-aware', () => { + let tmpDir; + let opencodeConfigDir; + + beforeEach(() => { + tmpDir = createTempProject(); + // Separate temp dir to act as the opencode global config dir + opencodeConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-opencode-')); + }); + + afterEach(() => { + cleanup(tmpDir); + cleanup(opencodeConfigDir); + }); + + // ── Test 1: opencode runtime resolves the opencode agents path ────────────── + + test('GSD_RUNTIME=opencode finds agents under OPENCODE_CONFIG_DIR/agents', () => { + // Place agents under the opencode config dir that getGlobalConfigDir('opencode') + // will return when OPENCODE_CONFIG_DIR is set. + const agentsDir = createAgentsInConfigDir(opencodeConfigDir); + + const result = runGsdTools( + ['init', 'quick', 'test description', '--raw'], + tmpDir, + { + GSD_RUNTIME: 'opencode', + OPENCODE_CONFIG_DIR: opencodeConfigDir, + // Ensure the process HOME does NOT have a conflicting ~/.claude/agents + // that might accidentally produce a false positive via GSD_AGENTS_DIR + // (we must NOT set GSD_AGENTS_DIR here — the whole point is that the fix + // uses the runtime-aware path without needing GSD_AGENTS_DIR). + } + ); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + + assert.strictEqual(output.agents_installed, true, + `agents_installed must be true when agents exist under OPENCODE_CONFIG_DIR/agents. ` + + `agents_dir=${output.agents_dir}, agent_runtime=${output.agent_runtime}`); + + assert.strictEqual(output.agent_runtime, 'opencode', + 'agent_runtime must be "opencode" when GSD_RUNTIME=opencode'); + + assert.strictEqual(output.agents_dir, agentsDir, + `agents_dir must point at the opencode agents dir (${agentsDir}), got: ${output.agents_dir}`); + }); + + // ── Test 2: claude fallback via GSD_AGENTS_DIR ────────────────────────────── + + test('default runtime (no GSD_RUNTIME) with GSD_AGENTS_DIR → agents_installed=true, agent_runtime=claude', () => { + // Classic GSD_AGENTS_DIR override: no runtime set, use the env shortcut + createAgentsInConfigDir(tmpDir); + // GSD_AGENTS_DIR points directly at the agents dir (not the config dir) + const directAgentsDir = path.join(tmpDir, 'agents'); + + const result = runGsdTools( + ['init', 'quick', 'test description', '--raw'], + tmpDir, + { + GSD_AGENTS_DIR: directAgentsDir, + // Explicitly unset GSD_RUNTIME so no runtime override applies + GSD_RUNTIME: '', + } + ); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + + assert.strictEqual(output.agents_installed, true, + `agents_installed must be true when GSD_AGENTS_DIR points at a populated agents dir. ` + + `agents_dir=${output.agents_dir}`); + + assert.strictEqual(output.agent_runtime, 'claude', + 'agent_runtime must be "claude" when no GSD_RUNTIME is set'); + + assert.strictEqual(output.agents_dir, directAgentsDir, + `agents_dir must match GSD_AGENTS_DIR override`); + }); + + // ── Test 3 (negative): opencode runtime, empty agents dir ─────────────────── + + test('GSD_RUNTIME=opencode with empty agents dir → agents_installed=false, agent_runtime still surfaced', () => { + // Create the opencode config dir but leave agents/ empty (no files) + const emptyAgentsDir = path.join(opencodeConfigDir, 'agents'); + fs.mkdirSync(emptyAgentsDir, { recursive: true }); + + const result = runGsdTools( + ['init', 'quick', 'test description', '--raw'], + tmpDir, + { + GSD_RUNTIME: 'opencode', + OPENCODE_CONFIG_DIR: opencodeConfigDir, + } + ); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + + assert.strictEqual(output.agents_installed, false, + 'agents_installed must be false when agents dir is empty'); + + assert.strictEqual(output.agent_runtime, 'opencode', + 'agent_runtime must still be surfaced even when agents are missing'); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/feat-3024-dynamic-routing.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:feat-3024-dynamic-routing (consolidation epic #1969 B3 #1972)", () => { +/** + * Feature test for issue #3024 — dynamic routing with failure-tier escalation. + * + * Adds a `dynamic_routing` block to .planning/config.json: + * + * { + * "dynamic_routing": { + * "enabled": true, + * "tier_models": { + * "light": "haiku", + * "standard": "sonnet", + * "heavy": "opus" + * }, + * "escalate_on_failure": true, + * "max_escalations": 1 + * } + * } + * + * Each agent has a default tier (light/standard/heavy). When dynamic + * routing is enabled, the resolver picks `tier_models[default_tier]` + * for the first attempt. On orchestrator-detected soft failure, the + * orchestrator calls the resolver again with `attempt: 1`, which + * returns the next tier up (capped at `max_escalations`). + * + * This PR delivers the JS-layer infrastructure: schema + tier map + + * resolver + escalation helpers. Orchestrator adoption is incremental + * follow-up — this PR's contract is the resolver function and the + * config it consumes. + * + * Resolution precedence (highest → lowest): + * 1. model_overrides[agent] (full IDs accepted; targeted) + * 2. dynamic_routing.tier_models[tier] (NEW; escalation-aware) + * 3. models[phase_type] (#3023; coarse phase-level) + * 4. model_profile (per-agent column) + * 5. Runtime default + * + * Tests are typed-IR / structural — assert on the value returned by + * resolveModelForTier or isValidConfigKey, not stdout/grep. + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { + resolveModelInternal, + resolveModelForTier, +} = require('../gsd-core/bin/lib/model-resolver.cjs'); +const { + AGENT_DEFAULT_TIERS, + VALID_AGENT_TIERS, + MODEL_PROFILES, + nextTier, +} = require('../gsd-core/bin/lib/model-profiles.cjs'); +const { isValidConfigKey } = require('../gsd-core/bin/lib/config-schema.cjs'); + +const { createTempDir, cleanup } = require('./helpers.cjs'); +const makeTmp = (prefix) => createTempDir(`gsd-3024-${prefix}-`); +function writeConfig(dir, config) { + const planningDir = path.join(dir, '.planning'); + fs.mkdirSync(planningDir, { recursive: true }); + fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify(config, null, 2)); +} +function rmr(p) { cleanup(p); } + +// ─── Schema: AGENT_DEFAULT_TIERS coverage + valid tier set ────────────────── + +describe('#3024 schema: every agent has a default tier (light/standard/heavy)', () => { + test('AGENT_DEFAULT_TIERS exported as a non-empty object', () => { + assert.equal(typeof AGENT_DEFAULT_TIERS, 'object'); + assert.ok(AGENT_DEFAULT_TIERS !== null); + assert.ok(Object.keys(AGENT_DEFAULT_TIERS).length > 0); + }); + + test('VALID_AGENT_TIERS exposes exactly {light, standard, heavy}', () => { + assert.deepStrictEqual([...VALID_AGENT_TIERS].sort(), ['heavy', 'light', 'standard']); + }); + + test('every agent in MODEL_PROFILES has a default tier', () => { + const missing = Object.keys(MODEL_PROFILES).filter((a) => !AGENT_DEFAULT_TIERS[a]); + assert.deepStrictEqual(missing, []); + }); + + test('every assigned tier is one of the three valid tiers', () => { + const invalid = Object.entries(AGENT_DEFAULT_TIERS).filter( + ([, t]) => !VALID_AGENT_TIERS.has(t) + ); + assert.deepStrictEqual(invalid, []); + }); +}); + +// ─── nextTier helper ──────────────────────────────────────────────────────── + +describe('#3024 nextTier helper', () => { + test('exported as a function', () => { + assert.equal(typeof nextTier, 'function'); + }); + + test('light → standard → heavy → heavy (caps at heavy)', () => { + assert.equal(nextTier('light'), 'standard'); + assert.equal(nextTier('standard'), 'heavy'); + assert.equal(nextTier('heavy'), 'heavy', 'already at top — stays at heavy'); + }); + + test('returns null for invalid input', () => { + assert.equal(nextTier('jumbo'), null); + assert.equal(nextTier(null), null); + assert.equal(nextTier(undefined), null); + }); +}); + +// ─── Resolver behavior: dynamic routing, disabled mode ────────────────────── + +describe('#3024 resolveModelForTier: disabled mode is a no-op (acceptance criterion 1)', () => { + let projectDir; + beforeEach(() => { projectDir = makeTmp('disabled'); }); + afterEach(() => { rmr(projectDir); }); + + test('exported as a function', () => { + assert.equal(typeof resolveModelForTier, 'function'); + }); + + test('with no dynamic_routing block, falls back to resolveModelInternal', () => { + writeConfig(projectDir, { model_profile: 'balanced' }); + // resolveModelForTier with attempt=0 must match resolveModelInternal. + const baseline = resolveModelInternal(projectDir, 'gsd-phase-researcher'); + assert.equal(resolveModelForTier(projectDir, 'gsd-phase-researcher', 0), baseline); + }); + + test('with dynamic_routing.enabled=false, attempt argument is ignored — same as resolveModelInternal', () => { + writeConfig(projectDir, { + model_profile: 'balanced', + dynamic_routing: { + enabled: false, + tier_models: { light: 'haiku', standard: 'sonnet', heavy: 'opus' }, + }, + }); + const baseline = resolveModelInternal(projectDir, 'gsd-phase-researcher'); + // attempt=0 and attempt=1 both ignored when disabled + assert.equal(resolveModelForTier(projectDir, 'gsd-phase-researcher', 0), baseline); + assert.equal(resolveModelForTier(projectDir, 'gsd-phase-researcher', 1), baseline); + }); +}); + +// ─── Resolver behavior: dynamic routing, enabled ──────────────────────────── + +describe('#3024 resolveModelForTier: enabled mode picks tier_models[default_tier]', () => { + let projectDir; + beforeEach(() => { projectDir = makeTmp('enabled'); }); + afterEach(() => { rmr(projectDir); }); + + test('attempt=0 returns tier_models[agent_default_tier] (acceptance criterion 2)', () => { + writeConfig(projectDir, { + model_profile: 'balanced', + dynamic_routing: { + enabled: true, + tier_models: { light: 'haiku', standard: 'sonnet', heavy: 'opus' }, + }, + }); + // gsd-codebase-mapper has light default tier per AGENT_DEFAULT_TIERS. + // CR nitpick (#3031): assert preconditions explicitly so a tier + // re-mapping in AGENT_DEFAULT_TIERS surfaces as a test failure + // instead of a silent skip. + assert.equal(AGENT_DEFAULT_TIERS['gsd-codebase-mapper'], 'light', + 'gsd-codebase-mapper expected to be light tier'); + assert.equal(resolveModelForTier(projectDir, 'gsd-codebase-mapper', 0), 'haiku'); + assert.equal(AGENT_DEFAULT_TIERS['gsd-planner'], 'heavy', + 'gsd-planner expected to be heavy tier'); + assert.equal(resolveModelForTier(projectDir, 'gsd-planner', 0), 'opus'); + }); + + test('attempt=1 escalates to next tier up (acceptance criterion 3)', () => { + writeConfig(projectDir, { + model_profile: 'balanced', + dynamic_routing: { + enabled: true, + tier_models: { light: 'haiku', standard: 'sonnet', heavy: 'opus' }, + escalate_on_failure: true, + max_escalations: 1, + }, + }); + // For an agent with default tier 'light', attempt=1 should give 'standard' tier model. + const lightAgent = Object.entries(AGENT_DEFAULT_TIERS).find(([, t]) => t === 'light')?.[0]; + assert.ok(lightAgent, 'AGENT_DEFAULT_TIERS must contain at least one light agent'); + assert.equal(resolveModelForTier(projectDir, lightAgent, 0), 'haiku'); + assert.equal(resolveModelForTier(projectDir, lightAgent, 1), 'sonnet'); + // For a 'standard' agent, attempt=1 should give 'heavy' model. + const stdAgent = Object.entries(AGENT_DEFAULT_TIERS).find(([, t]) => t === 'standard')?.[0]; + assert.ok(stdAgent, 'AGENT_DEFAULT_TIERS must contain at least one standard agent'); + assert.equal(resolveModelForTier(projectDir, stdAgent, 0), 'sonnet'); + assert.equal(resolveModelForTier(projectDir, stdAgent, 1), 'opus'); + }); + + test('attempts beyond max_escalations cap at the highest reachable tier (acceptance criterion 4)', () => { + writeConfig(projectDir, { + model_profile: 'balanced', + dynamic_routing: { + enabled: true, + tier_models: { light: 'haiku', standard: 'sonnet', heavy: 'opus' }, + escalate_on_failure: true, + max_escalations: 1, // cap at 1 escalation total + }, + }); + const lightAgent = Object.entries(AGENT_DEFAULT_TIERS).find(([, t]) => t === 'light')?.[0]; + assert.ok(lightAgent, 'AGENT_DEFAULT_TIERS must contain at least one light agent'); + // attempts beyond max_escalations should not exceed max_escalations' + // tier — i.e. attempt=2 with max=1 = same as attempt=1. + assert.equal(resolveModelForTier(projectDir, lightAgent, 2), 'sonnet', + 'attempt=2 with max_escalations=1 caps at attempt=1 tier'); + assert.equal(resolveModelForTier(projectDir, lightAgent, 5), 'sonnet'); + }); + + test('"heavy" agents stay at heavy (no tier above)', () => { + writeConfig(projectDir, { + model_profile: 'balanced', + dynamic_routing: { + enabled: true, + tier_models: { light: 'haiku', standard: 'sonnet', heavy: 'opus' }, + escalate_on_failure: true, + max_escalations: 2, + }, + }); + const heavyAgent = Object.entries(AGENT_DEFAULT_TIERS).find(([, t]) => t === 'heavy')?.[0]; + assert.ok(heavyAgent, 'AGENT_DEFAULT_TIERS must contain at least one heavy agent'); + assert.equal(resolveModelForTier(projectDir, heavyAgent, 0), 'opus'); + // Already at heavy — escalation cannot go higher. + assert.equal(resolveModelForTier(projectDir, heavyAgent, 1), 'opus'); + assert.equal(resolveModelForTier(projectDir, heavyAgent, 5), 'opus'); + }); + + test('default max_escalations is 1 when omitted', () => { + writeConfig(projectDir, { + model_profile: 'balanced', + dynamic_routing: { + enabled: true, + tier_models: { light: 'haiku', standard: 'sonnet', heavy: 'opus' }, + // max_escalations omitted — default to 1 + }, + }); + const lightAgent = Object.entries(AGENT_DEFAULT_TIERS).find(([, t]) => t === 'light')?.[0]; + assert.ok(lightAgent, 'AGENT_DEFAULT_TIERS must contain at least one light agent'); + // attempt=1 escalates; attempt=2 should cap at attempt=1 (default max=1) + assert.equal(resolveModelForTier(projectDir, lightAgent, 1), 'sonnet'); + assert.equal(resolveModelForTier(projectDir, lightAgent, 2), 'sonnet'); + }); + + // ─── CR Major (#3031): escalate_on_failure: false honored ────────────── + + test('escalate_on_failure:false disables escalation even when attempt > 0 (CR Major)', () => { + // Pre-fix bug: an orchestrator that always passes attempt+1 on retry + // would silently escalate even though the user opted out via + // escalate_on_failure:false. The kill-switch must short-circuit + // every attempt back to the default tier. + writeConfig(projectDir, { + model_profile: 'balanced', + dynamic_routing: { + enabled: true, + tier_models: { light: 'haiku', standard: 'sonnet', heavy: 'opus' }, + escalate_on_failure: false, // ← kill-switch + max_escalations: 5, + }, + }); + const lightAgent = Object.entries(AGENT_DEFAULT_TIERS).find(([, t]) => t === 'light')?.[0]; + assert.ok(lightAgent, 'AGENT_DEFAULT_TIERS must contain at least one light agent'); + // Every attempt must resolve to the default (light → haiku), + // regardless of how high the orchestrator bumped the counter. + assert.equal(resolveModelForTier(projectDir, lightAgent, 0), 'haiku'); + assert.equal(resolveModelForTier(projectDir, lightAgent, 1), 'haiku', + 'escalate_on_failure:false must not escalate even at attempt=1'); + assert.equal(resolveModelForTier(projectDir, lightAgent, 5), 'haiku'); + }); + + test('escalate_on_failure:true (explicit) escalates normally', () => { + // Sanity: explicit true matches the default truthy behavior. + writeConfig(projectDir, { + model_profile: 'balanced', + dynamic_routing: { + enabled: true, + tier_models: { light: 'haiku', standard: 'sonnet', heavy: 'opus' }, + escalate_on_failure: true, + max_escalations: 1, + }, + }); + const lightAgent = Object.entries(AGENT_DEFAULT_TIERS).find(([, t]) => t === 'light')?.[0]; + assert.ok(lightAgent); + assert.equal(resolveModelForTier(projectDir, lightAgent, 1), 'sonnet'); + }); +}); + +// ─── Resolver precedence ──────────────────────────────────────────────────── + +describe('#3024 precedence: per-agent override > dynamic_routing > models > profile', () => { + let projectDir; + beforeEach(() => { projectDir = makeTmp('precedence'); }); + afterEach(() => { rmr(projectDir); }); + + test('per-agent model_overrides beats dynamic_routing (acceptance criterion: override wins)', () => { + writeConfig(projectDir, { + model_profile: 'balanced', + model_overrides: { 'gsd-codebase-mapper': 'openai/gpt-5' }, + dynamic_routing: { + enabled: true, + tier_models: { light: 'haiku', standard: 'sonnet', heavy: 'opus' }, + }, + }); + // Per-agent override always wins, even at escalated attempt. + assert.equal(resolveModelForTier(projectDir, 'gsd-codebase-mapper', 0), 'openai/gpt-5'); + assert.equal(resolveModelForTier(projectDir, 'gsd-codebase-mapper', 1), 'openai/gpt-5'); + }); + + test('dynamic_routing beats phase-type models (#3023)', () => { + writeConfig(projectDir, { + model_profile: 'balanced', + models: { research: 'opus' }, // phase-type would say opus + dynamic_routing: { + enabled: true, + tier_models: { light: 'haiku', standard: 'sonnet', heavy: 'opus' }, + }, + }); + // gsd-codebase-mapper is research phase-type; phase-type would give 'opus', + // but dynamic routing (light default → haiku) wins. + if (AGENT_DEFAULT_TIERS['gsd-codebase-mapper'] === 'light') { + assert.equal(resolveModelForTier(projectDir, 'gsd-codebase-mapper', 0), 'haiku'); + } + }); +}); + +// ─── Schema validation ────────────────────────────────────────────────────── + +describe('#3024 config-schema: dynamic_routing.* validation', () => { + test('dynamic_routing.enabled is a valid config key', () => { + assert.equal(isValidConfigKey('dynamic_routing.enabled'), true); + }); + + test('dynamic_routing.escalate_on_failure is a valid config key', () => { + assert.equal(isValidConfigKey('dynamic_routing.escalate_on_failure'), true); + }); + + test('dynamic_routing.max_escalations is a valid config key', () => { + assert.equal(isValidConfigKey('dynamic_routing.max_escalations'), true); + }); + + test('dynamic_routing.tier_models. for each valid tier', () => { + for (const t of ['light', 'standard', 'heavy']) { + assert.equal(isValidConfigKey(`dynamic_routing.tier_models.${t}`), true); + } + }); + + test('unknown tier in tier_models is rejected', () => { + assert.equal(isValidConfigKey('dynamic_routing.tier_models.jumbo'), false); + assert.equal(isValidConfigKey('dynamic_routing.tier_models.medium'), false); + }); + + test('unknown dynamic_routing.* keys are rejected', () => { + assert.equal(isValidConfigKey('dynamic_routing.foo'), false); + assert.equal(isValidConfigKey('dynamic_routing'), false, + 'bare dynamic_routing (no field) must not be a config-set target'); + }); +}); + }); +} diff --git a/tests/model-resolver.test.cjs b/tests/model-resolver.test.cjs index 9f42093af..589a78e77 100644 --- a/tests/model-resolver.test.cjs +++ b/tests/model-resolver.test.cjs @@ -625,3 +625,358 @@ describe('ADVERSARIAL: edge cases', () => { assert.strictEqual(at0, at1); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-1829-inherit-model-profile.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-1829-inherit-model-profile (consolidation epic #1969 B3 #1972)", () => { +/** + * Regression tests for bug #1829 + * + * model_profile: "inherit" in .planning/config.json was not recognised as a + * valid profile. resolveModelInternal() silently fell back to "balanced", + * causing all agents to use "sonnet" instead of inheriting the parent model. + * + * Root cause in core.cjs: + * const profile = config.model_profile || 'balanced'; + * const agentModels = MODEL_PROFILES[agentType]; + * if (!agentModels) return 'sonnet'; + * const resolved = agentModels[profile] || agentModels['balanced'] || 'sonnet'; + * // agentModels['inherit'] is undefined → falls through to agentModels['balanced'] + * + * Fix 1 (core.cjs): add early return — if (profile === 'inherit') return 'inherit'; + * Fix 2 (verify.cjs): add 'inherit' to validProfiles so it doesn't trigger W004. + */ + +'use strict'; + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); + +const { resolveModelInternal } = require('../gsd-core/bin/lib/model-resolver.cjs'); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function writeConfig(tmpDir, obj) { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify(obj, null, 2) + ); +} + +function writeMinimalProjectMd(tmpDir) { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'PROJECT.md'), + '# Project\n\n## What This Is\n\nContent.\n\n## Core Value\n\nContent.\n\n## Requirements\n\nContent.\n' + ); +} + +function writeMinimalRoadmap(tmpDir) { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + '# Roadmap\n\n### Phase 1: First Phase\n' + ); +} + +function writeMinimalStateMd(tmpDir) { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + '# Session State\n\n## Current Position\n\nPhase: 1\n' + ); +} + +// ─── resolveModelInternal — inherit profile ─────────────────────────────────── + +describe('bug #1829: model_profile "inherit" — resolveModelInternal', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('returns "inherit" for gsd-planner when model_profile is "inherit"', () => { + writeConfig(tmpDir, { model_profile: 'inherit' }); + assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-planner'), 'inherit'); + }); + + test('returns "inherit" for gsd-executor when model_profile is "inherit"', () => { + writeConfig(tmpDir, { model_profile: 'inherit' }); + assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-executor'), 'inherit'); + }); + + test('returns "inherit" for gsd-phase-researcher when model_profile is "inherit"', () => { + writeConfig(tmpDir, { model_profile: 'inherit' }); + assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-phase-researcher'), 'inherit'); + }); + + test('returns "inherit" for gsd-codebase-mapper when model_profile is "inherit"', () => { + writeConfig(tmpDir, { model_profile: 'inherit' }); + assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-codebase-mapper'), 'inherit'); + }); + + test('returns "inherit" for gsd-verifier when model_profile is "inherit"', () => { + writeConfig(tmpDir, { model_profile: 'inherit' }); + assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-verifier'), 'inherit'); + }); + + test('returns "inherit" for unknown agent with inherit profile', () => { + writeConfig(tmpDir, { model_profile: 'inherit' }); + assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-nonexistent'), 'inherit'); + }); + + test('per-agent override takes precedence over inherit profile', () => { + writeConfig(tmpDir, { + model_profile: 'inherit', + model_overrides: { 'gsd-executor': 'haiku' }, + }); + // Override wins even when profile is inherit + assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-executor'), 'haiku'); + // Other agents without override still inherit + assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-planner'), 'inherit'); + }); + + test('does not silently fall back to "sonnet" (the original bug)', () => { + writeConfig(tmpDir, { model_profile: 'inherit' }); + // Before the fix, this returned 'sonnet' (via balanced fallback) + const model = resolveModelInternal(tmpDir, 'gsd-planner'); + assert.notStrictEqual(model, 'sonnet', 'inherit profile must not silently fall back to sonnet'); + }); +}); + +// ─── resolve-model CLI — inherit profile ────────────────────────────────────── + +describe('bug #1829: model_profile "inherit" — resolve-model CLI', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('CLI resolve-model returns "inherit" for gsd-executor with inherit profile', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ model_profile: 'inherit' }, null, 2) + ); + + const result = runGsdTools('resolve-model gsd-executor', tmpDir); + assert.ok(result.success, `resolve-model failed: ${result.error}`); + + const parsed = JSON.parse(result.output); + assert.strictEqual(parsed.model, 'inherit'); + assert.strictEqual(parsed.profile, 'inherit'); + }); + + test('CLI resolve-model returns "inherit" for gsd-planner with inherit profile', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ model_profile: 'inherit' }, null, 2) + ); + + const result = runGsdTools('resolve-model gsd-planner', tmpDir); + assert.ok(result.success, `resolve-model failed: ${result.error}`); + + const parsed = JSON.parse(result.output); + assert.strictEqual(parsed.model, 'inherit'); + }); +}); + +// ─── verify health — inherit profile is not a validation error ──────────────── + +describe('bug #1829: model_profile "inherit" — validate health does not warn W004', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + writeMinimalProjectMd(tmpDir); + writeMinimalRoadmap(tmpDir); + writeMinimalStateMd(tmpDir); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-first-phase'), { recursive: true }); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('does not emit W004 for model_profile "inherit"', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ + model_profile: 'inherit', + workflow: { + research: true, + plan_check: true, + verifier: true, + nyquist_validation: true, + }, + }, null, 2) + ); + + const result = runGsdTools('validate health', tmpDir); + assert.ok(result.success, `validate health failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.ok( + !output.warnings.some(w => w.code === 'W004'), + `inherit profile must not trigger W004: ${JSON.stringify(output.warnings)}` + ); + }); + + test('still emits W004 for genuinely invalid model_profile values', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ model_profile: 'invalid-profile' }, null, 2) + ); + + const result = runGsdTools('validate health', tmpDir); + assert.ok(result.success, `validate health failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.ok( + output.warnings.some(w => w.code === 'W004'), + `Invalid profile should trigger W004: ${JSON.stringify(output.warnings)}` + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-492-effort-manifest-fallback.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-492-effort-manifest-fallback (consolidation epic #1969 B3 #1972)", () => { +'use strict'; +/** + * bug-492-effort-manifest-fallback.test.cjs + * + * Verifies resolveEffortInternal's fallback chain when no project config.json + * is present. + * + * Isolation strategy: every test that injects custom effort values writes + * them to a per-test ~/.gsd/defaults.json rooted under a tmpHome, pointed at + * via GSD_HOME. This avoids mutating the module-level CANONICAL_CONFIG_DEFAULTS + * singleton (which caused independence violations under parallel runs). + * + * Test 1 (pure manifest fallback): tmpDir WITH .planning/ but no config.json. + * GSD_HOME points to a bare tmpHome (no defaults.json). loadConfig sees + * .planning/ → returns effort:null → model-resolver reads CANONICAL_CONFIG_DEFAULTS + * directly for routing_tier_defaults. + * + * Tests 2-4 (global-defaults path): bare tmpDir (no .planning/) so loadConfig + * hits the ~/.gsd/defaults.json branch. A test-scoped defaults.json injects + * the desired effort sub-object; model-resolver then takes the effortCfg + * (non-null) branch — no singleton touched. + */ + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { cleanup } = require('./helpers.cjs'); +const { resolveEffortInternal } = require('../gsd-core/bin/lib/model-resolver.cjs'); + +/** Create a bare temp directory with no .planning/ structure */ +function createBareTmpDir(prefix = 'gsd-test-') { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +/** Create a temp home dir and write effort config into .gsd/defaults.json */ +function createTmpHomeWithEffort(effortConfig) { + const tmpHome = createBareTmpDir('gsd-home-'); + const gsdDir = path.join(tmpHome, '.gsd'); + fs.mkdirSync(gsdDir, { recursive: true }); + fs.writeFileSync( + path.join(gsdDir, 'defaults.json'), + JSON.stringify({ effort: effortConfig }) + ); + return tmpHome; +} + +describe('#492 manifest effort fallback', () => { + // These tests manage GSD_HOME per-test, so no shared beforeEach/afterEach. + + test('routing_tier_defaults manifest fallback still works when no config and no defaults.json', (t) => { + // .planning/ exists → loadConfig returns effort:null → model-resolver reads + // CANONICAL_CONFIG_DEFAULTS['effort']['routing_tier_defaults']['heavy'] = "xhigh". + const tmpDir = createBareTmpDir(); + fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true }); + const tmpHome = createBareTmpDir('gsd-home-'); + process.env.GSD_HOME = tmpHome; + t.after(() => { + delete process.env.GSD_HOME; + cleanup(tmpDir); + cleanup(tmpHome); + }); + + // gsd-planner's default tier is "heavy"; manifest routing_tier_defaults.heavy = "xhigh" + assert.strictEqual(resolveEffortInternal(tmpDir, 'gsd-planner'), 'xhigh'); + }); + + test('global-defaults effort.agent_overrides wins over routing_tier_defaults when no project config', (t) => { + // bare tmpDir (no .planning/) → loadConfig reads ~/.gsd/defaults.json + // which supplies effort.agent_overrides → resolveEffortInternal returns that value. + const tmpDir = createBareTmpDir(); + const tmpHome = createTmpHomeWithEffort({ agent_overrides: { 'gsd-planner': 'max' } }); + process.env.GSD_HOME = tmpHome; + t.after(() => { + delete process.env.GSD_HOME; + cleanup(tmpDir); + cleanup(tmpHome); + }); + + assert.strictEqual(resolveEffortInternal(tmpDir, 'gsd-planner'), 'max'); + }); + + test('global-defaults effort.default consulted for unknown agent with no project config', (t) => { + // effort.default in defaults.json wins for an agent with no tier mapping. + const tmpDir = createBareTmpDir(); + const tmpHome = createTmpHomeWithEffort({ default: 'max' }); + process.env.GSD_HOME = tmpHome; + t.after(() => { + delete process.env.GSD_HOME; + cleanup(tmpDir); + cleanup(tmpHome); + }); + + assert.strictEqual(resolveEffortInternal(tmpDir, 'fictional-agent-xyz-492'), 'max'); + }); + + test('global-defaults agent_overrides takes precedence over routing_tier_defaults', (t) => { + // agent_overrides is checked first (step 2), so "minimal" wins over + // routing_tier_defaults.heavy = "xhigh" (step 3). + const tmpDir = createBareTmpDir(); + const tmpHome = createTmpHomeWithEffort({ + agent_overrides: { 'gsd-planner': 'minimal' }, + routing_tier_defaults: { heavy: 'xhigh' }, + }); + process.env.GSD_HOME = tmpHome; + t.after(() => { + delete process.env.GSD_HOME; + cleanup(tmpDir); + cleanup(tmpHome); + }); + + assert.strictEqual(resolveEffortInternal(tmpDir, 'gsd-planner'), 'minimal'); + }); +}); + }); +} diff --git a/tests/phase.test.cjs b/tests/phase.test.cjs index 2a49af4c6..5632a3090 100644 --- a/tests/phase.test.cjs +++ b/tests/phase.test.cjs @@ -6899,3 +6899,1111 @@ describe('bug #2268: parallel discuss — all undiscussed phases marked is_next_ }); }); } + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/4-phase-complete-cjs-regression.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:4-phase-complete-cjs-regression (consolidation epic #1969 B3 #1972)", () => { +'use strict'; + +/** + * Regression test for issue #4 (open-gsd/gsd-core): + * bin/lib/phase.cjs cmdPhaseComplete — non-idempotent and unclamped. + * + * Root cause (pre-fix): + * cmdPhaseComplete blindly increments "Completed Phases" by 1 on every call + * (parseInt(completedRaw, 10) + 1) and recomputes Progress without a clamp. + * Double-calling yields Completed Phases +2 (not +1), and Progress can exceed 100%. + * + * Fix: derive completed_phases from ROADMAP Complete-row count (idempotent) and + * clamp percent to 100. This mirrors the SDK fix in phase-lifecycle.ts + * (commit deriving from ROADMAP, referenced as "PR #3520" in issue #4). + * + * Note on test structure: phase-command-router.cjs delegates to the SDK when + * the SDK dist is present, so the CJS path is exercised by calling + * cmdPhaseComplete directly (bypassing the router), which is the actual + * function containing the bug. + * + * References: + * - ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md) — architectural foundation + * - /tmp/adr-3524-review-findings.md — architectural justification + * - Issue #4 (open-gsd/gsd-core) + */ + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); + +const { cleanup, runGsdTools } = require('./helpers.cjs'); + +// ── Load cmdPhaseComplete directly from phase.cjs (bypass the SDK router) ──── +// phase-command-router.cjs delegates to SDK when available; we must test the +// CJS implementation directly since that is where the bug lives. +const phaseModule = require('../gsd-core/bin/lib/phase.cjs'); +const { cmdPhaseComplete } = phaseModule; + +function writePassedVerificationFile(phaseDir, phase = '01') { + fs.writeFileSync(path.join(phaseDir, `${phase}-VERIFICATION.md`), [ + '---', + 'status: passed', + '---', + '', + '# Verification', + '', + ].join('\n')); +} + +// ── Fixture builder ────────────────────────────────────────────────────────── + +/** + * Creates a minimal fixture project with: + * - ROADMAP.md with a 4-column progress table (Phase | Plans | Status | Completed) + * - REQUIREMENTS.md with a phase-scoped REQ-ID and Traceability row + * - STATE.md with Completed Phases: 0 and Total Phases: 2 (Progress 0%) + * - Phase 01 directory with one plan+summary (to satisfy phase complete guard) + * - Phase 02 directory (next phase) + */ +function createFixture(prefix = 'gsd-4-regression-') { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + const planningDir = path.join(tmpDir, '.planning'); + const phasesDir = path.join(planningDir, 'phases'); + fs.mkdirSync(phasesDir, { recursive: true }); + + // ROADMAP.md: Phase 01 not yet complete, Phase 02 not started + // 4-column progress table: Phase | Plans Complete | Status | Completed + const roadmap = [ + '# Roadmap', + '', + '- [ ] Phase 01: Foundation', + '- [ ] Phase 02: API', + '', + '### Phase 01: Foundation', + '**Goal:** Build the foundation', + '**Requirements:** REQ-1', + '**Plans:** 1 plans', + '', + '### Phase 02: API', + '**Goal:** Build the API', + '', + '## Progress', + '', + '| Phase | Plans Complete | Status | Completed |', + '|-------|----------------|--------|-----------|', + '| 01. Foundation | 0/1 | Not started | - |', + '| 02. API | 0/1 | Not started | - |', + '', + ].join('\n'); + fs.writeFileSync(path.join(planningDir, 'ROADMAP.md'), roadmap); + + const requirements = [ + '# Requirements', + '', + '## Functional Requirements', + '', + '- [ ] **REQ-1** Foundation must be complete.', + '', + '## Traceability', + '', + '| Requirement | Phase | Status |', + '|-------------|-------|--------|', + '| REQ-1 | Phase 01 | Pending |', + '', + ].join('\n'); + fs.writeFileSync(path.join(planningDir, 'REQUIREMENTS.md'), requirements); + + // STATE.md: Completed Phases: 0, Total Phases: 2, Progress: 0% + // Uses body-field format (bold **Field:** value) so the CJS handler's + // stateExtractField/stateReplaceField path is exercised. + const state = [ + '# State', + '', + '**Current Phase:** 01', + '**Current Phase Name:** Foundation', + '**Status:** In progress', + '**Current Plan:** 01-01', + '**Last Activity:** 2025-01-01', + '**Last Activity Description:** Working on phase 1', + '**Completed Phases:** 0', + '**Total Phases:** 2', + '**Progress:** 0%', + '', + ].join('\n'); + fs.writeFileSync(path.join(planningDir, 'STATE.md'), state); + + // Phase 01 directory with a PLAN and SUMMARY so phase complete guard passes + const phase01Dir = path.join(phasesDir, '01-foundation'); + fs.mkdirSync(phase01Dir, { recursive: true }); + fs.writeFileSync(path.join(phase01Dir, '01-01-PLAN.md'), '# Plan 1\nDo the work.\n'); + fs.writeFileSync(path.join(phase01Dir, '01-01-SUMMARY.md'), '# Summary 1\nDone.\n'); + writePassedVerificationFile(phase01Dir); + + // Phase 02 directory (needed for "next phase" detection) + fs.mkdirSync(path.join(phasesDir, '02-api'), { recursive: true }); + + return tmpDir; +} + +function readStateMd(tmpDir) { + return fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf8'); +} + +function roadmapCompletionSnapshot(roadmapContent) { + const snapshot = { + phaseCheckboxes: [], + progressRows: [], + }; + + for (const line of roadmapContent.split(/\r?\n/)) { + let match = line.match(/^- \[([ x])\] Phase ([^:]+): (.*)$/); + if (match) { + snapshot.phaseCheckboxes.push({ + checked: match[1] === 'x', + phase: match[2].trim(), + title: match[3].replace(/\s+\(completed [^)]+\)$/, '').trim(), + }); + continue; + } + + match = line.match(/^\|\s*(\d+[A-Z]?(?:\.\d+)*)\.?\s*([^|]*)\|\s*([^|]*)\|\s*([^|]*)\|\s*([^|]*)\|$/i); + if (match) { + snapshot.progressRows.push({ + phase: match[1].trim(), + title: match[2].trim(), + plans: match[3].trim(), + status: match[4].trim(), + completed: match[5].trim(), + }); + } + } + + return snapshot; +} + +function extractField(stateContent, fieldName) { + const escaped = fieldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const boldMatch = stateContent.match(new RegExp(`\\*\\*${escaped}:\\*\\*[ \\t]*(.+)`, 'i')); + if (boldMatch) return boldMatch[1].trim(); + const plainMatch = stateContent.match(new RegExp(`^${escaped}:[ \\t]*(.+)`, 'im')); + return plainMatch ? plainMatch[1].trim() : null; +} + +function extractFrontmatterField(stateContent, fieldName) { + // Extract from YAML frontmatter block + const fmMatch = stateContent.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!fmMatch) return null; + const fm = fmMatch[1]; + // Handle both scalar and nested (progress.completed_phases) + const parts = fieldName.split('.'); + if (parts.length === 1) { + const m = fm.match(new RegExp(`^${parts[0]}:\\s*(.+)`, 'm')); + return m ? m[1].trim() : null; + } + // Nested: e.g. "progress.completed_phases" + const sectionMatch = fm.match(new RegExp(`^${parts[0]}:\\s*\\n([\\s\\S]*?)(?=\\n[a-z]|$)`, 'm')); + if (!sectionMatch) return null; + const sectionContent = sectionMatch[1]; + const fieldMatch = sectionContent.match(new RegExp(`^\\s+${parts[1]}:\\s*(.+)`, 'm')); + return fieldMatch ? fieldMatch[1].trim() : null; +} + +// Capture stdout from cmdPhaseComplete (it calls output() which writes to stdout) +function capturePhaseComplete(cwd, phaseNum) { + // We invoke gsd-tools directly for the full CJS path, but with GSD_DISABLE_SDK_BRIDGE=1 + // to force the CJS implementation. Since no env var disables bridge, we call cmdPhaseComplete + // directly and redirect output capture. + const chunks = []; + const origWrite = process.stdout.write.bind(process.stdout); + const origErrWrite = process.stderr.write.bind(process.stderr); + process.stdout.write = (chunk) => { chunks.push(chunk); return true; }; + process.stderr.write = () => true; + try { + cmdPhaseComplete(cwd, phaseNum, false); + } finally { + process.stdout.write = origWrite; + process.stderr.write = origErrWrite; + } + return chunks.join(''); +} + +// ── T1: Double invocation must NOT double-increment Completed Phases ───────── + +describe('issue #4 (CJS): cmdPhaseComplete — idempotency (blind-increment bug)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createFixture(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('T1: double invocation does NOT double-increment Completed Phases in STATE.md body', () => { + // First call — legitimate completion + capturePhaseComplete(tmpDir, '1'); + + const stateAfter1 = readStateMd(tmpDir); + const completedAfter1Body = extractField(stateAfter1, 'Completed Phases'); + const completedAfter1Fm = extractFrontmatterField(stateAfter1, 'progress.completed_phases'); + // After first call: Completed Phases in the body should be 1 + // (derived from ROADMAP: 1 Complete row after marking phase 01 complete) + // OR the YAML frontmatter completed_phases should be 1 + const completedAfter1 = completedAfter1Body || completedAfter1Fm; + assert.equal( + completedAfter1, + '1', + `After first call: completed_phases should be 1.\n` + + `Body field: ${completedAfter1Body}, FM field: ${completedAfter1Fm}\n\n` + + `STATE:\n${stateAfter1}`, + ); + + // Second call on the same phase — must be idempotent + capturePhaseComplete(tmpDir, '1'); + + const stateAfter2 = readStateMd(tmpDir); + const completedAfter2Body = extractField(stateAfter2, 'Completed Phases'); + const completedAfter2Fm = extractFrontmatterField(stateAfter2, 'progress.completed_phases'); + const completedAfter2 = completedAfter2Body || completedAfter2Fm; + + // Pre-fix: completedAfter2 would be "2" (blind increment: 1+1=2) + // Post-fix: must remain "1" (derived from ROADMAP) + assert.equal( + completedAfter2, + '1', + `T1 FAILED: Completed Phases was double-incremented.\n` + + `After first call: ${completedAfter1}, after second call: ${completedAfter2}.\n` + + `This is the #4 non-idempotency bug — blind parseInt+1 instead of deriving from ROADMAP.\n\n` + + `STATE after second call (body: ${completedAfter2Body}, fm: ${completedAfter2Fm}):\n${stateAfter2}`, + ); + }); + + test('rolls back ROADMAP when STATE write fails during phase completion', (t) => { + const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); + const reqPath = path.join(tmpDir, '.planning', 'REQUIREMENTS.md'); + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + const originalRoadmap = fs.readFileSync(roadmapPath, 'utf8'); + const originalReq = fs.readFileSync(reqPath, 'utf8'); + const originalState = fs.readFileSync(statePath, 'utf8'); + const originalWriteFileSync = fs.writeFileSync; + + t.mock.method(fs, 'writeFileSync', function injectedStateWriteFailure(target, ...args) { + const targetPath = String(target); + const isStatePublish = targetPath === statePath || targetPath === `${statePath}.tmp.${process.pid}`; + if (isStatePublish) { + const err = new Error('injected STATE.md write failure'); + err.code = 'EIO'; + throw err; + } + return originalWriteFileSync.call(this, target, ...args); + }); + + assert.throws( + () => capturePhaseComplete(tmpDir, '1'), + /injected STATE\.md write failure/, + ); + + const roadmapAfter = fs.readFileSync(roadmapPath, 'utf8'); + const reqAfter = fs.readFileSync(reqPath, 'utf8'); + const stateAfter = fs.readFileSync(statePath, 'utf8'); + + assert.deepEqual( + roadmapCompletionSnapshot(roadmapAfter), + roadmapCompletionSnapshot(originalRoadmap), + 'ROADMAP.md should roll back to its original completion state', + ); + assert.equal(reqAfter, originalReq, 'REQUIREMENTS.md should roll back when STATE.md write fails'); + assert.equal(stateAfter, originalState, 'STATE.md should remain unchanged after injected write failure'); + }); + + test('rolls back ROADMAP when REQUIREMENTS write fails during phase completion', (t) => { + const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); + const reqPath = path.join(tmpDir, '.planning', 'REQUIREMENTS.md'); + const originalRoadmap = fs.readFileSync(roadmapPath, 'utf8'); + const originalReq = fs.readFileSync(reqPath, 'utf8'); + const originalWriteFileSync = fs.writeFileSync; + + t.mock.method(fs, 'writeFileSync', function injectedRequirementsWriteFailure(target, ...args) { + const targetPath = String(target); + if (targetPath === reqPath || targetPath === `${reqPath}.tmp.${process.pid}`) { + const err = new Error('injected REQUIREMENTS.md write failure'); + err.code = 'EIO'; + throw err; + } + return originalWriteFileSync.call(this, target, ...args); + }); + + assert.throws( + () => capturePhaseComplete(tmpDir, '1'), + /injected REQUIREMENTS\.md write failure/, + ); + + assert.deepEqual( + roadmapCompletionSnapshot(fs.readFileSync(roadmapPath, 'utf8')), + roadmapCompletionSnapshot(originalRoadmap), + 'ROADMAP.md should roll back when the REQUIREMENTS write fails', + ); + assert.equal(fs.readFileSync(reqPath, 'utf8'), originalReq, 'REQUIREMENTS.md should be unchanged'); + }); + + test('reports rollback failure when restoring an earlier planning file fails', (t) => { + const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); + const reqPath = path.join(tmpDir, '.planning', 'REQUIREMENTS.md'); + const originalWriteFileSync = fs.writeFileSync; + let requirementsWriteFailed = false; + + t.mock.method(fs, 'writeFileSync', function injectedRollbackFailure(target, ...args) { + const targetPath = String(target); + if (targetPath === reqPath || targetPath === `${reqPath}.tmp.${process.pid}`) { + requirementsWriteFailed = true; + const err = new Error('injected REQUIREMENTS.md write failure'); + err.code = 'EIO'; + throw err; + } + if (requirementsWriteFailed && (targetPath === roadmapPath || targetPath === `${roadmapPath}.tmp.${process.pid}`)) { + const err = new Error('injected ROADMAP.md rollback failure'); + err.code = 'EIO'; + throw err; + } + return originalWriteFileSync.call(this, target, ...args); + }); + + assert.throws( + () => capturePhaseComplete(tmpDir, '1'), + /injected REQUIREMENTS\.md write failure[\s\S]*WARNING: rollback failed while restoring[\s\S]*injected ROADMAP\.md rollback failure/, + ); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Regressions: phase complete preserves completion date (#1161) +// Tests drive the REAL handler (cmdPhaseComplete) via the CLI entry point +// `runGsdTools('phase complete ')` so the fix in phase.cts is exercised +// end-to-end rather than hitting the roadmap.cjs helper in isolation. +// ───────────────────────────────────────────────────────────────────────────── + +/** Extract the Completed cell from the progress table row for a given phase number. + * The Completed column is always the LAST cell, regardless of whether the table is + * 4-col (Phase | Plans | Status | Completed) or 5-col (Phase | Milestone | Plans | Status | Completed). + */ +function extractCompletedCell(roadmapContent, phaseNum) { + // Match the full progress table row whose first cell starts with the phase number. + // Use [^|\n] to avoid crossing line boundaries. Capture everything up to the final '|'. + const re = new RegExp(`^(\\|\\s*${phaseNum}[^|\\n]*(?:\\|[^|\\n]*)*)\\|\\s*$`, 'm'); + const m = roadmapContent.match(re); + if (!m) return null; + // m[1] = '| 01. Foundation | 1/1 | Complete | 2026-01-01 ' + // Split on '|' → ['', ' 01. Foundation ', ' 1/1 ', ' Complete ', ' 2026-01-01 '] + // Drop the leading empty string and take the last element. + const cells = m[1].split('|').slice(1); // drop leading '' + return cells[cells.length - 1].trim(); +} + +/** + * Build a minimal 4-col ROADMAP project fixture whose Phase 01 row already has + * the Completed cell set to `existingDate` and Status `Complete`. + * The phase directory has plan+summary so `phase complete 1` can run. + * + * @param {string} existingDate - value in the Completed cell ('2026-01-01', '-', ' ', etc.) + * @param {boolean} [alreadyComplete] - if true the checkbox is already checked and status Complete + */ +function create4ColFixture(existingDate, alreadyComplete = true) { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1161-4col-')); + const planDir = path.join(tmpDir, '.planning'); + const phasesDir = path.join(planDir, 'phases'); + fs.mkdirSync(phasesDir, { recursive: true }); + + const checkbox = alreadyComplete ? '[x]' : '[ ]'; + const checkboxSuffix = alreadyComplete ? ' (completed 2026-01-01)' : ''; + const status = alreadyComplete ? 'Complete ' : 'Not started'; + + const roadmap = [ + '# Roadmap', + '', + `- ${checkbox} Phase 01: Foundation${checkboxSuffix}`, + '- [ ] Phase 02: API', + '', + '### Phase 01: Foundation', + '**Goal:** Build the foundation', + '**Plans:** 1/1 plans complete', + '', + '### Phase 02: API', + '**Goal:** Build the API', + '', + '## Progress', + '', + '| Phase | Plans Complete | Status | Completed |', + '|-------|----------------|--------|-----------|', + `| 01. Foundation | 1/1 | ${status} | ${existingDate} |`, + '| 02. API | 0/1 | Not started | - |', + '', + ].join('\n'); + fs.writeFileSync(path.join(planDir, 'ROADMAP.md'), roadmap); + + const state = [ + '# State', + '', + '**Current Phase:** 01', + '**Current Phase Name:** Foundation', + '**Status:** In progress', + '**Current Plan:** 01-01', + '**Last Activity:** 2025-01-01', + '**Last Activity Description:** Working on phase 1', + '**Completed Phases:** 0', + '**Total Phases:** 2', + '**Progress:** 0%', + '', + ].join('\n'); + fs.writeFileSync(path.join(planDir, 'STATE.md'), state); + + const phase01Dir = path.join(phasesDir, '01-foundation'); + fs.mkdirSync(phase01Dir, { recursive: true }); + fs.writeFileSync(path.join(phase01Dir, '01-01-PLAN.md'), '# Plan 1\nDo the work.\n'); + fs.writeFileSync(path.join(phase01Dir, '01-01-SUMMARY.md'), '# Summary 1\nDone.\n'); + writePassedVerificationFile(phase01Dir); + + fs.mkdirSync(path.join(phasesDir, '02-api'), { recursive: true }); + + return tmpDir; +} + +/** + * Build a minimal 5-col ROADMAP project fixture (Phase | Milestone | Plans | Status | Completed). + * Phase 01 row already has Completed cell set to `existingDate`. + */ +function create5ColFixture(existingDate, alreadyComplete = true) { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1161-5col-')); + const planDir = path.join(tmpDir, '.planning'); + const phasesDir = path.join(planDir, 'phases'); + fs.mkdirSync(phasesDir, { recursive: true }); + + const checkbox = alreadyComplete ? '[x]' : '[ ]'; + const checkboxSuffix = alreadyComplete ? ' (completed 2026-01-01)' : ''; + const status = alreadyComplete ? 'Complete ' : 'Not started'; + + const roadmap = [ + '# Roadmap', + '', + `- ${checkbox} Phase 01: Foundation${checkboxSuffix}`, + '- [ ] Phase 02: API', + '', + '### Phase 01: Foundation', + '**Goal:** Build the foundation', + '**Plans:** 1/1 plans complete', + '', + '### Phase 02: API', + '**Goal:** Build the API', + '', + '## Progress', + '', + '| Phase | Milestone | Plans | Status | Completed |', + '|-------|-----------|-------|--------|-----------|', + `| 01. Foundation | v1.0 | 1/1 | ${status} | ${existingDate} |`, + '| 02. API | v1.0 | 0/1 | Not started | - |', + '', + ].join('\n'); + fs.writeFileSync(path.join(planDir, 'ROADMAP.md'), roadmap); + + const state = [ + '# State', + '', + '**Current Phase:** 01', + '**Current Phase Name:** Foundation', + '**Status:** In progress', + '**Current Plan:** 01-01', + '**Last Activity:** 2025-01-01', + '**Last Activity Description:** Working on phase 1', + '**Completed Phases:** 0', + '**Total Phases:** 2', + '**Progress:** 0%', + '', + ].join('\n'); + fs.writeFileSync(path.join(planDir, 'STATE.md'), state); + + const phase01Dir = path.join(phasesDir, '01-foundation'); + fs.mkdirSync(phase01Dir, { recursive: true }); + fs.writeFileSync(path.join(phase01Dir, '01-01-PLAN.md'), '# Plan 1\nDo the work.\n'); + fs.writeFileSync(path.join(phase01Dir, '01-01-SUMMARY.md'), '# Summary 1\nDone.\n'); + writePassedVerificationFile(phase01Dir); + + fs.mkdirSync(path.join(phasesDir, '02-api'), { recursive: true }); + + return tmpDir; +} + +// Fixed historical instant — will never collide with a real today() in CI. +const PINNED_MS_1161 = Date.parse('2021-03-22T10:00:00.000Z'); +const PINNED_DATE_1161 = '2021-03-22'; +// Env passed to runGsdTools to pin the clock in the subprocess SUT. +const PINNED_CLOCK_ENV = { + GSD_TEST_MODE: '1', + GSD_NOW_MS: String(PINNED_MS_1161), +}; + +describe('regressions: phase complete preserves completion date (#1161)', () => { + let tmpDir; + + afterEach(() => { + cleanup(tmpDir); + }); + + // ── (a) 4-col: already Complete with a date — repeat phase complete must NOT overwrite ── + + test('#1161 (a): 4-col ROADMAP — repeat `phase complete 1` preserves existing Completed date', () => { + // Arrange: Row is already Complete with '2026-01-01'. + tmpDir = create4ColFixture('2026-01-01', true); + const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); + + // Act: run `phase complete 1` via the real CLI handler, clock pinned to PINNED_DATE. + const result = runGsdTools('phase complete 1', tmpDir, PINNED_CLOCK_ENV); + assert.ok(result.success, `phase complete failed: ${result.error || result.output}`); + + // Assert: Completed cell must still be '2026-01-01', NOT the pinned '2021-03-22'. + const after = fs.readFileSync(roadmapPath, 'utf8'); + const completedCell = extractCompletedCell(after, '01'); + assert.strictEqual( + completedCell, + '2026-01-01', + `#1161 (a) FAILED: repeat phase complete on 4-col table overwrote the existing date.\n` + + `Expected '2026-01-01', got '${completedCell}'.\n` + + `Pinned clock was '${PINNED_DATE_1161}' — if that appears the date was overwritten.\n\n` + + `ROADMAP after:\n${after}`, + ); + }); + + // ── (b) 5-col: already Complete with a date — repeat phase complete must NOT overwrite ── + + test('#1161 (b): 5-col ROADMAP — repeat `phase complete 1` preserves existing Completed date', () => { + // Arrange: 5-col table row is already Complete with '2026-01-01'. + tmpDir = create5ColFixture('2026-01-01', true); + const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); + + // Act: run `phase complete 1` via the real CLI handler, clock pinned to PINNED_DATE. + const result = runGsdTools('phase complete 1', tmpDir, PINNED_CLOCK_ENV); + assert.ok(result.success, `phase complete failed: ${result.error || result.output}`); + + // Assert: Completed cell must still be '2026-01-01', NOT the pinned '2021-03-22'. + const after = fs.readFileSync(roadmapPath, 'utf8'); + const completedCell5 = extractCompletedCell(after, '01'); + assert.strictEqual( + completedCell5, + '2026-01-01', + `#1161 (b) FAILED: repeat phase complete on 5-col table overwrote the existing date.\n` + + `Expected '2026-01-01', got '${completedCell5}'.\n` + + `Pinned clock was '${PINNED_DATE_1161}' — if that appears the date was overwritten.\n\n` + + `ROADMAP after:\n${after}`, + ); + }); + + // ── (c) First-time completion (placeholder '-') must stamp the pinned date ── + + test('#1161 (c): 4-col ROADMAP — first `phase complete 1` (placeholder date) stamps pinned date', () => { + // Arrange: Row has '-' as Completed cell and is Not started (never completed). + tmpDir = create4ColFixture('-', false); + const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); + + // Act: first-time phase complete. + const result = runGsdTools('phase complete 1', tmpDir, PINNED_CLOCK_ENV); + assert.ok(result.success, `phase complete failed: ${result.error || result.output}`); + + // Assert: Completed cell is now the pinned date. + const after = fs.readFileSync(roadmapPath, 'utf8'); + const completedCell = extractCompletedCell(after, '01'); + assert.strictEqual( + completedCell, + PINNED_DATE_1161, + `#1161 (c) FAILED: first-time completion should stamp '${PINNED_DATE_1161}', got '${completedCell}'.\n\n` + + `ROADMAP after:\n${after}`, + ); + }); + + // ── (d) Whitespace-only Completed cell is treated as empty and gets stamped ── + + test('#1161 (d): 4-col ROADMAP — whitespace-only Completed cell treated as empty, gets stamped', () => { + // Arrange: Row has ' ' (spaces) as Completed cell. + tmpDir = create4ColFixture(' ', false); + const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); + + // Act: first-time phase complete. + const result = runGsdTools('phase complete 1', tmpDir, PINNED_CLOCK_ENV); + assert.ok(result.success, `phase complete failed: ${result.error || result.output}`); + + // Assert: Completed cell is now the pinned date (whitespace was treated as empty). + const after = fs.readFileSync(roadmapPath, 'utf8'); + const completedCell = extractCompletedCell(after, '01'); + assert.strictEqual( + completedCell, + PINNED_DATE_1161, + `#1161 (d) FAILED: whitespace-only Completed cell should be stamped '${PINNED_DATE_1161}', got '${completedCell}'.\n\n` + + `ROADMAP after:\n${after}`, + ); + }); + + // ── (e) Non-date garbage in Completed cell is self-healed and gets re-stamped ── + + test('#1161 (e): 5-col ROADMAP — non-date garbage Completed cell is self-healed and re-stamped', () => { + // Arrange: 5-col row is already Complete but the Completed cell contains 'TBD' + // (a non-date garbage value that the old guard would have preserved). + tmpDir = create5ColFixture('TBD', true); + const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); + + // Act: run `phase complete 1` via the real CLI handler, clock pinned to PINNED_DATE. + const result = runGsdTools('phase complete 1', tmpDir, PINNED_CLOCK_ENV); + assert.ok(result.success, `phase complete failed: ${result.error || result.output}`); + + // Assert: garbage 'TBD' must be replaced with the pinned date (self-heal). + // Pre-Fix 2: the old guard (existingDate && existingDate !== '-') would preserve 'TBD'. + // Post-Fix 2: the date-shape guard (/^\d{4}-\d{2}-\d{2}$/) rejects 'TBD' → re-stamps. + const after = fs.readFileSync(roadmapPath, 'utf8'); + const completedCell = extractCompletedCell(after, '01'); + assert.strictEqual( + completedCell, + PINNED_DATE_1161, + `#1161 (e) FAILED: non-date garbage 'TBD' in Completed cell should be self-healed to '${PINNED_DATE_1161}', got '${completedCell}'.\n` + + `Old guard (non-empty && !== '-') would preserve 'TBD'. New guard must require a date shape.\n\n` + + `ROADMAP after:\n${after}`, + ); + }); +}); + +// ── T2: Progress percent must never exceed 100% ────────────────────────────── + +describe('issue #4 (CJS): cmdPhaseComplete — progress percent clamp', () => { + let tmpDir; + + afterEach(() => { + cleanup(tmpDir); + }); + + test('T2: Progress percent never exceeds 100 after double invocation', () => { + tmpDir = createFixture(); + + // Pre-load STATE.md with Completed Phases: 1, Total Phases: 1 (already 100%) + // so that a blind +1 on a second call would yield 200% + let stateContent = readStateMd(tmpDir); + stateContent = stateContent.replace('**Completed Phases:** 0', '**Completed Phases:** 1'); + stateContent = stateContent.replace('**Total Phases:** 2', '**Total Phases:** 1'); + stateContent = stateContent.replace('**Progress:** 0%', '**Progress:** 100%'); + fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateContent); + + // Also update ROADMAP to show just 1 phase total + const roadmap = [ + '# Roadmap', + '', + '- [ ] Phase 01: Foundation', + '', + '### Phase 01: Foundation', + '**Goal:** Build the foundation', + '**Plans:** 1 plans', + '', + '## Progress', + '', + '| Phase | Plans Complete | Status | Completed |', + '|-------|----------------|--------|-----------|', + '| 01. Foundation | 0/1 | Not started | - |', + '', + ].join('\n'); + fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap); + + // First call + capturePhaseComplete(tmpDir, '1'); + // Second call — this is the problematic one + capturePhaseComplete(tmpDir, '1'); + + const stateAfterBoth = readStateMd(tmpDir); + + // Check body Progress field + const progressStr = extractField(stateAfterBoth, 'Progress'); + const fmPercent = extractFrontmatterField(stateAfterBoth, 'progress.percent'); + + // Try to extract numeric percent from either source + const bodyPercentMatch = progressStr && progressStr.match(/(\d+)%/); + const bodyPercent = bodyPercentMatch ? parseInt(bodyPercentMatch[1], 10) : null; + const fmPercentNum = fmPercent ? parseInt(fmPercent, 10) : null; + + // At least one of body or frontmatter percent must exist and be ≤ 100 + const anyPercent = bodyPercent ?? fmPercentNum; + assert.ok( + anyPercent !== null, + `T2: Could not find any percent value in STATE.md\n\nSTATE:\n${stateAfterBoth}`, + ); + assert.ok( + anyPercent <= 100, + `T2 FAILED: Progress percent exceeds 100.\n` + + `Body Progress: "${progressStr}" (${bodyPercent}%), FM percent: ${fmPercentNum}%\n` + + `This is the #4 unclamped-percent bug — (N+1)/total can exceed 100.\n\n` + + `STATE:\n${stateAfterBoth}`, + ); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Regressions: issue #1159 — Defect A +// VERIFICATION.md with `previous_status: gaps_found` in the body but +// `status: passed` in frontmatter must NOT emit a "has unresolved gaps" warning. +// The bug: /status: gaps_found/.test(fullContent) matches the substring inside +// `previous_status: gaps_found`, causing a false positive. +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Build a minimal project fixture with a VERIFICATION.md file whose + * frontmatter status is `verFmStatus` and whose body contains `previous_status: gaps_found`. + * Phase 01 has a plan+summary; Phase 02 exists for next-phase detection. + */ +function createVerificationFixture(verFmStatus) { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1159-verif-')); + const planDir = path.join(tmpDir, '.planning'); + const phasesDir = path.join(planDir, 'phases'); + const phase01Dir = path.join(phasesDir, '01-foundation'); + fs.mkdirSync(phase01Dir, { recursive: true }); + fs.mkdirSync(path.join(phasesDir, '02-api'), { recursive: true }); + + fs.writeFileSync(path.join(planDir, 'ROADMAP.md'), [ + '# Roadmap', + '', + '- [ ] Phase 01: Foundation', + '- [ ] Phase 02: API', + '', + '### Phase 01: Foundation', + '**Goal:** Build the foundation', + '**Plans:** 1 plans', + '', + '## Progress', + '', + '| Phase | Plans Complete | Status | Completed |', + '|-------|----------------|--------|-----------|', + '| 01. Foundation | 0/1 | Not started | - |', + '| 02. API | 0/1 | Not started | - |', + '', + ].join('\n')); + + fs.writeFileSync(path.join(planDir, 'STATE.md'), [ + '# State', + '', + '**Current Phase:** 01', + '**Current Phase Name:** Foundation', + '**Status:** In progress', + '**Completed Phases:** 0', + '**Total Phases:** 2', + '**Progress:** 0%', + '', + ].join('\n')); + + // No REQUIREMENTS.md intentionally (not needed for this defect check) + + fs.writeFileSync(path.join(phase01Dir, '01-01-PLAN.md'), '# Plan 1\nDo the work.\n'); + fs.writeFileSync(path.join(phase01Dir, '01-01-SUMMARY.md'), '# Summary 1\nDone.\n'); + + // The VERIFICATION.md has the CURRENT status in frontmatter but historical + // `previous_status: gaps_found` in the body — this is the false-positive trigger. + fs.writeFileSync(path.join(phase01Dir, '01-VERIFICATION.md'), [ + '---', + `status: ${verFmStatus}`, + 'phase: "01"', + '---', + '', + '# Verification', + '', + '', + 'previous_status: gaps_found', + '', + '## Summary', + 'All checks passed on re-run.', + '', + ].join('\n')); + + return tmpDir; +} + +describe('issue #1159 (Defect A): VERIFICATION.md historical metadata must not trigger gap warning', () => { + let tmpDir; + + afterEach(() => { + cleanup(tmpDir); + }); + + test( + '#1159-A-1: status:passed + previous_status:gaps_found in body → NO "has unresolved gaps" warning', + () => { + tmpDir = createVerificationFixture('passed'); + const { output } = runGsdTools(['phase', 'complete', '1'], tmpDir); + // The output is JSON; parse and check warnings array + const parsed = JSON.parse(output); + const warnings = parsed.warnings || []; + const gapWarnings = warnings.filter((w) => /unresolved gaps/i.test(w)); + assert.equal( + gapWarnings.length, + 0, + `#1159-A-1 FAILED: got false gap warning(s) when frontmatter status=passed.\n` + + `Warnings: ${JSON.stringify(warnings)}\n` + + `(The regex /status: gaps_found/ matched 'previous_status: gaps_found' in the body.)`, + ); + }, + ); + + test( + '#1159-A-2 (boundary): status:gaps_found in frontmatter → blocks phase completion', + () => { + tmpDir = createVerificationFixture('gaps_found'); + const result = runGsdTools(['--json-errors', 'phase', 'complete', '1'], tmpDir); + assert.equal(result.success, false, 'gaps_found verification must block phase completion'); + const parsed = JSON.parse(result.error); + assert.equal(parsed.reason, 'phase_verification_incomplete'); + assert.match(parsed.message, /Gaps found/i); + }, + ); + + test( + '#1159-A-3 (boundary): status:human_needed in frontmatter → blocks phase completion', + () => { + tmpDir = createVerificationFixture('human_needed'); + const result = runGsdTools(['--json-errors', 'phase', 'complete', '1'], tmpDir); + assert.equal(result.success, false, 'human_needed verification must block phase completion'); + const parsed = JSON.parse(result.error); + assert.equal(parsed.reason, 'phase_verification_incomplete'); + assert.match(parsed.message, /Human verification required/i); + }, + ); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Regressions: issue #1159 — Defect B +// Requirement IDs (e.g. FILE-001) that appear under explicitly deferred/future/v2 +// sections in REQUIREMENTS.md must NOT be flagged as "missing from Traceability". +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Build a minimal project fixture with a REQUIREMENTS.md that has: + * - An active requirement ACTIVE-001 that IS in the Traceability table + * - A deferred requirement DEFER-001 under a "Deferred v2 Requirements" heading + * that is NOT in the Traceability table (correctly out of scope) + * - Optionally a truly-missing active requirement MISSING-001 (not in table) + */ +function createDeferredReqFixture({ includeMissingActive = false } = {}) { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1159-deferred-')); + const planDir = path.join(tmpDir, '.planning'); + const phasesDir = path.join(planDir, 'phases'); + const phase01Dir = path.join(phasesDir, '01-foundation'); + fs.mkdirSync(phase01Dir, { recursive: true }); + fs.mkdirSync(path.join(phasesDir, '02-api'), { recursive: true }); + + const missingActiveLines = includeMissingActive + ? ['', '- **MISSING-001** Active req not in traceability table.'] + : []; + + fs.writeFileSync(path.join(planDir, 'REQUIREMENTS.md'), [ + '# Requirements', + '', + '## Functional Requirements', + '', + '- **ACTIVE-001** Core feature must work.', + ...missingActiveLines, + '', + '## Deferred v2 Requirements', + '', + '- **DEFER-001** Nice-to-have for v2, explicitly out of scope.', + '', + '## Future Backlog', + '', + '- **FUTURE-001** Consider for next major release.', + '', + '## Traceability', + '', + '| Requirement | Phase | Status |', + '|-------------|-------|--------|', + '| ACTIVE-001 | Phase 01 | Pending |', + '', + ].join('\n')); + + // Roadmap references ACTIVE-001 for phase 01 + fs.writeFileSync(path.join(planDir, 'ROADMAP.md'), [ + '# Roadmap', + '', + '- [ ] Phase 01: Foundation', + '- [ ] Phase 02: API', + '', + '### Phase 01: Foundation', + '**Goal:** Build the foundation', + '**Requirements:** ACTIVE-001', + '**Plans:** 1 plans', + '', + '## Progress', + '', + '| Phase | Plans Complete | Status | Completed |', + '|-------|----------------|--------|-----------|', + '| 01. Foundation | 0/1 | Not started | - |', + '| 02. API | 0/1 | Not started | - |', + '', + ].join('\n')); + + fs.writeFileSync(path.join(planDir, 'STATE.md'), [ + '# State', + '', + '**Current Phase:** 01', + '**Current Phase Name:** Foundation', + '**Status:** In progress', + '**Completed Phases:** 0', + '**Total Phases:** 2', + '**Progress:** 0%', + '', + ].join('\n')); + + fs.writeFileSync(path.join(phase01Dir, '01-01-PLAN.md'), '# Plan 1\nDo the work.\n'); + fs.writeFileSync(path.join(phase01Dir, '01-01-SUMMARY.md'), '# Summary 1\nDone.\n'); + writePassedVerificationFile(phase01Dir); + + return tmpDir; +} + +describe('issue #1159 (Defect B): deferred/future requirement IDs must not trigger traceability warning', () => { + let tmpDir; + + afterEach(() => { + cleanup(tmpDir); + }); + + test( + '#1159-B-1: IDs under "Deferred v2 Requirements" and "Future Backlog" sections → NO traceability warning', + () => { + tmpDir = createDeferredReqFixture({ includeMissingActive: false }); + const { output } = runGsdTools(['phase', 'complete', '1'], tmpDir); + const parsed = JSON.parse(output); + const warnings = parsed.warnings || []; + const traceWarnings = warnings.filter((w) => /Traceability/i.test(w)); + assert.equal( + traceWarnings.length, + 0, + `#1159-B-1 FAILED: got false traceability warning(s) for deferred/future IDs.\n` + + `Warnings: ${JSON.stringify(warnings)}\n` + + `(DEFER-001 and FUTURE-001 are under deferred/future sections and must be ignored.)`, + ); + }, + ); + + test( + '#1159-B-2 (boundary): truly-missing ACTIVE ID (not in table, not in deferred section) → DOES warn', + () => { + tmpDir = createDeferredReqFixture({ includeMissingActive: true }); + const { output } = runGsdTools(['phase', 'complete', '1'], tmpDir); + const parsed = JSON.parse(output); + const warnings = parsed.warnings || []; + const traceWarnings = warnings.filter((w) => /Traceability/i.test(w)); + assert.ok( + traceWarnings.length > 0, + `#1159-B-2 FAILED: expected traceability warning for MISSING-001 (active, not in table) but got none.\n` + + `Warnings: ${JSON.stringify(warnings)}`, + ); + // Verify MISSING-001 is specifically mentioned + const mentionsMissing = traceWarnings.some((w) => w.includes('MISSING-001')); + assert.ok( + mentionsMissing, + `#1159-B-2 FAILED: warning exists but MISSING-001 not mentioned.\n` + + `Traceability warnings: ${JSON.stringify(traceWarnings)}`, + ); + }, + ); + + test( + '#1159-B-3 (boundary): deferred IDs must not contaminate warning even when active ID is also missing', + () => { + tmpDir = createDeferredReqFixture({ includeMissingActive: true }); + const { output } = runGsdTools(['phase', 'complete', '1'], tmpDir); + const parsed = JSON.parse(output); + const warnings = parsed.warnings || []; + const traceWarnings = warnings.filter((w) => /Traceability/i.test(w)); + // DEFER-001 and FUTURE-001 must NOT appear in the traceability warnings + const mentionsDefer = traceWarnings.some((w) => w.includes('DEFER-001') || w.includes('FUTURE-001')); + assert.ok( + !mentionsDefer, + `#1159-B-3 FAILED: deferred IDs (DEFER-001/FUTURE-001) appeared in traceability warning.\n` + + `Traceability warnings: ${JSON.stringify(traceWarnings)}`, + ); + }, + ); + + test( + '#1159-B-4 (subheading): IDs under sub-headings of a deferred section are also suppressed', + () => { + // Codex adversarial finding: splitting on EVERY heading failed to propagate + // deferred status to sub-headings (e.g. "## Future Backlog" → "### Sub"). + // The fix uses heading-depth tracking so sub-headings inherit deferred state. + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1159-subhead-')); + const planDir = path.join(tmpDir, '.planning'); + const phasesDir = path.join(planDir, 'phases'); + const phase01Dir = path.join(phasesDir, '01-foundation'); + fs.mkdirSync(phase01Dir, { recursive: true }); + fs.mkdirSync(path.join(phasesDir, '02-api'), { recursive: true }); + + fs.writeFileSync(path.join(planDir, 'REQUIREMENTS.md'), [ + '# Requirements', + '', + '## Functional Requirements', + '', + '- **ACTIVE-001** Core feature.', + '', + '## Future Backlog', + '', + '### Sub-category A', + '', + '- **SUB-001** This is under a sub-heading of a deferred section.', + '', + '## Traceability', + '', + '| Requirement | Phase | Status |', + '|-------------|-------|--------|', + '| ACTIVE-001 | Phase 01 | Pending |', + '', + ].join('\n')); + + fs.writeFileSync(path.join(planDir, 'ROADMAP.md'), [ + '# Roadmap', + '', + '- [ ] Phase 01: Foundation', + '- [ ] Phase 02: API', + '', + '### Phase 01: Foundation', + '**Goal:** Build the foundation', + '**Requirements:** ACTIVE-001', + '**Plans:** 1 plans', + '', + '## Progress', + '', + '| Phase | Plans Complete | Status | Completed |', + '|-------|----------------|--------|-----------|', + '| 01. Foundation | 0/1 | Not started | - |', + '| 02. API | 0/1 | Not started | - |', + '', + ].join('\n')); + + fs.writeFileSync(path.join(planDir, 'STATE.md'), [ + '# State', + '', + '**Current Phase:** 01', + '**Completed Phases:** 0', + '**Total Phases:** 2', + '**Progress:** 0%', + '', + ].join('\n')); + + fs.writeFileSync(path.join(phase01Dir, '01-01-PLAN.md'), '# Plan 1\n'); + fs.writeFileSync(path.join(phase01Dir, '01-01-SUMMARY.md'), '# Summary 1\n'); + writePassedVerificationFile(phase01Dir); + + const { output } = runGsdTools(['phase', 'complete', '1'], tmpDir); + const parsed = JSON.parse(output); + const warnings = parsed.warnings || []; + const traceWarnings = warnings.filter((w) => /Traceability/i.test(w)); + const mentionsSub = traceWarnings.some((w) => w.includes('SUB-001')); + assert.ok( + !mentionsSub, + `#1159-B-4 FAILED: SUB-001 (under sub-heading of deferred section) appeared in warning.\n` + + `Traceability warnings: ${JSON.stringify(traceWarnings)}`, + ); + }, + ); +}); + }); +} diff --git a/tests/plan-phase-drift-guard.test.cjs b/tests/plan-phase-drift-guard.test.cjs index aabd10db5..eb8699015 100644 --- a/tests/plan-phase-drift-guard.test.cjs +++ b/tests/plan-phase-drift-guard.test.cjs @@ -260,3 +260,724 @@ describe('plan-phase workflow: attempt-based Agent availability gate (#922)', () ); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2948-spike-wrap-up-dispatch.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2948-spike-wrap-up-dispatch (consolidation epic #1969 B3 #1972)", () => { +/** + * Regression test for bug #2948 + * + * `/gsd:spike --wrap-up` was silently no-oping because: + * 1. `commands/gsd/spike.md` listed `--wrap-up` as a flag but had no dispatch block. + * 2. `workflows/spike.md` still referenced the deleted `/gsd-spike-wrap-up` entry-point + * instead of the correct `/gsd:spike --wrap-up` form. + * + * Fix: + * - `commands/gsd/spike.md` now has a dispatch block that routes `--wrap-up` to + * spike-wrap-up.md, and spike-wrap-up.md is listed in execution_context so the + * runtime can find it. + * - `workflows/spike.md` companion references updated from `/gsd-spike-wrap-up` to + * `/gsd:spike --wrap-up`. + */ + +// allow-test-rule: source-text-is-the-product (see #2948) +// commands/gsd/*.md files ARE what the runtime loads — testing their +// frontmatter and section content tests the deployed system-prompt contract. + +'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 SPIKE_CMD_PATH = path.join(__dirname, '..', 'commands', 'gsd', 'spike.md'); +const SPIKE_WORKFLOW_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'spike.md'); + +/** + * Parse YAML frontmatter + body from a markdown file. + * Returns a shallow { key: value } map of frontmatter fields plus `_body`. + * Mirrors the parseFrontmatter utility used in enh-2792-namespace-skills.test.cjs. + */ +function parseFrontmatter(content) { + const lines = content.split(/\r?\n/); + + // Frontmatter must start at the very first line; a mid-file '---' is a + // horizontal rule, not a frontmatter delimiter. + if (lines[0]?.trim() !== '---') { + return { _body: content }; + } + + let closeIdx = -1; + for (let i = 1; i < lines.length; i += 1) { + if (lines[i].trim() === '---') { + closeIdx = i; + break; + } + } + assert.ok(closeIdx !== -1, 'frontmatter block must be delimited by --- on its own lines'); + const fm = {}; + for (const line of lines.slice(1, closeIdx)) { + const m = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/); + if (!m) continue; + const [, key, raw] = m; + fm[key] = raw.trim().replace(/^["']|["']$/g, ''); + } + fm._body = lines.slice(closeIdx + 1).join('\n'); + return fm; +} + +/** + * Extract the text content of a named XML-like section from a markdown body. + * Returns null if the section is absent. + */ +function extractSection(body, tag) { + const open = `<${tag}>`; + const close = ``; + const start = body.indexOf(open); + const end = body.indexOf(close); + if (start === -1 || end === -1) return null; + return body.slice(start + open.length, end); +} + +/** + * Parse the @-prefixed workflow references out of an execution_context section. + * Returns an array of resolved reference strings (@ stripped). + */ +function parseExecutionContextRefs(section) { + return section + .split(/\r?\n/) + .map(l => l.trim()) + .filter(l => l.startsWith('@')) + .map(l => l.slice(1).trim()); +} + +describe('bug-2948: /gsd:spike --wrap-up dispatch wiring', () => { + describe('commands/gsd/spike.md — frontmatter and section contract', () => { + test('spike.md command file exists and has valid frontmatter', () => { + assert.ok(fs.existsSync(SPIKE_CMD_PATH), 'commands/gsd/spike.md should exist'); + const fm = parseFrontmatter(fs.readFileSync(SPIKE_CMD_PATH, 'utf-8')); + assert.ok(fm.name, 'frontmatter must have a name field'); + }); + + test('argument-hint frontmatter field advertises --wrap-up flag', () => { + const fm = parseFrontmatter(fs.readFileSync(SPIKE_CMD_PATH, 'utf-8')); + assert.ok( + fm['argument-hint'] && fm['argument-hint'].includes('--wrap-up'), + `argument-hint must advertise --wrap-up; got: "${fm['argument-hint']}"` + ); + }); + + test('execution_context section includes spike-wrap-up workflow reference', () => { + const fm = parseFrontmatter(fs.readFileSync(SPIKE_CMD_PATH, 'utf-8')); + const execSection = extractSection(fm._body, 'execution_context'); + assert.ok(execSection !== null, 'spike.md must have an section'); + const refs = parseExecutionContextRefs(execSection); + assert.ok( + refs.some(r => r.includes('spike-wrap-up')), + `execution_context must declare a spike-wrap-up reference so the runtime can load the workflow; ` + + `declared refs: ${JSON.stringify(refs)}` + ); + }); + + test('process section dispatches first-token --wrap-up to spike-wrap-up workflow', () => { + const fm = parseFrontmatter(fs.readFileSync(SPIKE_CMD_PATH, 'utf-8')); + const processSection = extractSection(fm._body, 'process'); + assert.ok(processSection, 'spike.md must have a section'); + + const rules = processSection + .split(/\r?\n/) + .map(line => line.trim()) + .filter(Boolean); + + const wrapUpRule = rules.find(line => line.startsWith('- If it is `--wrap-up`:')); + const fallbackRule = rules.find(line => line.startsWith('- Otherwise:')); + + assert.ok( + wrapUpRule && wrapUpRule.includes('strip the flag') && wrapUpRule.includes('spike-wrap-up'), + 'process must define a --wrap-up branch that strips the flag and routes to spike-wrap-up' + ); + assert.ok( + fallbackRule && fallbackRule.includes('spike workflow'), + 'process must define an Otherwise fallback to the normal spike workflow' + ); + }); + }); + + describe('gsd-core/workflows/spike.md — companion references', () => { + test('spike workflow file exists', () => { + assert.ok(fs.existsSync(SPIKE_WORKFLOW_PATH), 'gsd-core/workflows/spike.md should exist'); + }); + + test('does NOT reference the deleted /gsd-spike-wrap-up entry-point', () => { + const fm = parseFrontmatter(fs.readFileSync(SPIKE_WORKFLOW_PATH, 'utf-8')); + assert.ok( + !fm._body.includes('/gsd-spike-wrap-up'), + 'workflows/spike.md must not reference the deleted /gsd-spike-wrap-up command; use /gsd:spike --wrap-up instead' + ); + }); + + test('references /gsd:spike --wrap-up as the canonical wrap-up invocation', () => { + const fm = parseFrontmatter(fs.readFileSync(SPIKE_WORKFLOW_PATH, 'utf-8')); + assert.ok( + fm._body.includes('/gsd:spike --wrap-up'), + 'workflows/spike.md must reference /gsd:spike --wrap-up as the canonical wrap-up command' + ); + }); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2949-sketch-wrap-up-dispatch.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2949-sketch-wrap-up-dispatch (consolidation epic #1969 B3 #1972)", () => { +// allow-test-rule: source-text-is-the-product (see #2949) +// 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. + +/** + * GSD Tests — /gsd:sketch --wrap-up silently no-ops (#2949) + * + * The --wrap-up flag was documented in commands/gsd/sketch.md but never dispatched. + * The sketch-wrap-up.md micro-skill entry point was deleted in #2790 and the dispatch + * wiring was never added to the command or workflow. + */ + +'use strict'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.resolve(__dirname, '..'); +const SKETCH_COMMAND = path.join(ROOT, 'commands/gsd/sketch.md'); +const SKETCH_WORKFLOW = path.join(ROOT, 'gsd-core/workflows/sketch.md'); + +describe('bug-2949: sketch --wrap-up dispatch wiring', () => { + test('commands/gsd/sketch.md contains --wrap-up dispatch logic', () => { + const content = fs.readFileSync(SKETCH_COMMAND, 'utf8'); + assert.ok( + content.includes('--wrap-up'), + 'sketch.md should contain --wrap-up dispatch logic' + ); + // The dispatch should route to sketch-wrap-up workflow + assert.ok( + content.includes('sketch-wrap-up'), + 'sketch.md should reference sketch-wrap-up in dispatch logic' + ); + }); + + test('commands/gsd/sketch.md has sketch-wrap-up in execution_context section', () => { + const content = fs.readFileSync(SKETCH_COMMAND, 'utf8'); + // Find execution_context block + const execCtxMatch = content.match(/([\s\S]*?)<\/execution_context>/); + assert.ok(execCtxMatch, 'sketch.md must have an block'); + const execCtx = execCtxMatch[1]; + assert.ok( + execCtx.includes('sketch-wrap-up'), + `execution_context block should include sketch-wrap-up workflow; got: ${execCtx}` + ); + }); + + test('workflows/sketch.md does NOT contain old /gsd-sketch-wrap-up form', () => { + const content = fs.readFileSync(SKETCH_WORKFLOW, 'utf8'); + assert.ok( + !content.includes('/gsd-sketch-wrap-up'), + 'workflows/sketch.md must not reference the old /gsd-sketch-wrap-up command' + ); + }); + + test('workflows/sketch.md DOES contain new /gsd:sketch --wrap-up form', () => { + const content = fs.readFileSync(SKETCH_WORKFLOW, 'utf8'); + assert.ok( + content.includes('/gsd:sketch --wrap-up'), + 'workflows/sketch.md should reference /gsd:sketch --wrap-up (the new form)' + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3156-plan-phase-opencode-dispatch.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3156-plan-phase-opencode-dispatch (consolidation epic #1969 B3 #1972)", () => { +'use strict'; + +// allow-test-rule: source-text-is-the-product (see #3156) +// commands/gsd/*.md files are the deployed skill surface. Their frontmatter +// IS the runtime contract. Checking frontmatter fields checks deployed behaviour. + +/** + * #3156 — plan-phase auto-dispatches to gsd-planner subagent on OpenCode, + * losing Task tool access. + * + * Root cause: commands/gsd/plan-phase.md had `agent: gsd-planner` in its + * frontmatter. Per OpenCode docs, `agent: ` in a command causes + * auto-dispatch to a subagent context where the Agent (Task spawner) tool is + * unavailable. Orchestrator commands that need to spawn subagents via the + * Agent tool must NOT carry an `agent:` frontmatter directive. + * + * This test parses the YAML frontmatter of every commands/gsd/*.md file and + * asserts: + * 1. No command file has an `agent:` frontmatter directive at all. + * (The directive causes OpenCode to auto-dispatch, breaking any command + * that relies on the Agent tool to spawn subagents.) + * 2. Any command whose allowed-tools includes `Agent` (an orchestrator) must + * not have `agent:` in its frontmatter. + */ + +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 COMMANDS_DIR = path.join(ROOT, 'commands', 'gsd'); + +/** Parse the YAML frontmatter block between the first two `---` delimiters. */ +function parseFrontmatter(content) { + const lines = content.split(/\r?\n/); + if (lines[0].trim() !== '---') return {}; + const end = lines.findIndex((line, idx) => idx > 0 && line.trim() === '---'); + if (end === -1) return {}; + const fm = {}; + let currentKey = null; + for (const line of lines.slice(1, end)) { + const kv = line.match(/^([a-zA-Z0-9_-]+):\s*(.*)/); + if (kv) { + currentKey = kv[1]; + fm[currentKey] = kv[2].trim(); + } else if (currentKey && line.match(/^\s+-\s+/)) { + const val = line.replace(/^\s+-\s+/, '').trim(); + fm[currentKey] = fm[currentKey] ? fm[currentKey] + '\n' + val : val; + } + } + return fm; +} + +/** Return the list of tools from the allowed-tools frontmatter block. */ +function allowedTools(fm) { + const raw = fm['allowed-tools']; + if (!raw) return []; + // Multi-line YAML list: each entry on its own line + if (raw.includes('\n')) { + return raw.split('\n').map(t => t.trim()).filter(Boolean); + } + return raw.split(',').map(t => t.trim()).filter(Boolean); +} + +const commandFiles = fs + .readdirSync(COMMANDS_DIR) + .filter(f => f.endsWith('.md')) + .map(f => ({ + name: f, + full: path.join(COMMANDS_DIR, f), + content: fs.readFileSync(path.join(COMMANDS_DIR, f), 'utf-8'), + })); + +// ─── No command may carry `agent:` ──────────────────────────────────────────── +// +// OpenCode interprets `agent: ` as "auto-dispatch to this subagent", +// which removes the Agent (subagent-spawner) tool from the command's context. +// Any orchestrator command is immediately broken. Commands that need to run in +// the main agent context (i.e., all GSD commands) must omit this directive. + +describe('#3156 — no command file may have an `agent:` frontmatter directive', () => { + for (const { name, content } of commandFiles) { + test(`${name}: no agent: directive in frontmatter`, () => { + const fm = parseFrontmatter(content); + assert.ok( + !Object.prototype.hasOwnProperty.call(fm, 'agent'), + `${name}: has \`agent: ${fm['agent']}\` in frontmatter — ` + + 'this causes OpenCode to auto-dispatch to a subagent context where the ' + + 'Agent tool is unavailable, breaking orchestrator workflows. ' + + 'Remove the `agent:` directive so the command runs in the main agent context.', + ); + }); + } +}); + +// ─── Orchestrator commands must not have `agent:` ──────────────────────────── +// +// Redundant with the above (belt-and-suspenders), but captures the precise +// failure mode from #3156: a command whose allowed-tools includes `Agent` +// relies on spawning subagents. Pairing that with `agent:` is self-defeating. + +describe('#3156 — orchestrator commands (allowed-tools: Agent) must not have agent:', () => { + const orchestrators = commandFiles.filter(({ content }) => { + const fm = parseFrontmatter(content); + const tools = allowedTools(fm); + return tools.includes('Agent'); + }); + + for (const { name, content } of orchestrators) { + test(`${name}: orchestrator must not carry agent: directive`, () => { + const fm = parseFrontmatter(content); + assert.ok( + !Object.prototype.hasOwnProperty.call(fm, 'agent'), + `${name}: allowed-tools includes Agent (orchestrator) but also has ` + + `\`agent: ${fm['agent']}\` — OpenCode will auto-dispatch to a subagent ` + + 'where Agent is unavailable, making the orchestrator unable to spawn ' + + 'researcher/planner/checker subagents. Remove the `agent:` directive.', + ); + }); + } +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-2310-chunked-plan-phase.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-2310-chunked-plan-phase (consolidation epic #1969 B3 #1972)", () => { +// allow-test-rule: source-text-is-the-product (see #2310) +// 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'; + + +/** + * Tests for #2310: plan-phase chunked mode + filesystem fallback. + * + * Context: on Windows (and occasionally other platforms), gsd-planner's + * Task() call may never return even though the subagent finished writing all + * PLAN.md files to disk. The orchestrator hangs indefinitely. Two mitigations: + * + * 1. Filesystem fallback (steps 9a, 11a): if the Task() return lacks the + * expected marker but PLAN.md files exist on disk, surface a recoverable + * prompt instead of hanging/failing silently. + * + * 2. Chunked mode (step 8.5): --chunked flag / workflow.plan_chunked config + * splits the single long planner Task into (a) a short outline Task and + * (b) N short single-plan Tasks. Each Task is shorter-lived, the + * orchestrator can commit work incrementally, and a hang loses only one + * plan instead of the entire phase. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const PLAN_PHASE = path.join( + __dirname, '..', 'gsd-core', 'workflows', 'plan-phase.md' +); + +const PLANNER_AGENT = path.join(__dirname, '..', 'agents', 'gsd-planner.md'); +const PLANNER_CHUNKED_REF = path.join(__dirname, '..', 'gsd-core', 'references', 'planner-chunked.md'); +const CONFIG_SCHEMA = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'config-schema.cjs'); +const CONFIGURATION_MD = path.join(__dirname, '..', 'docs', 'CONFIGURATION.md'); + +describe('plan-phase.md — filesystem fallback (#2310)', () => { + const content = fs.readFileSync(PLAN_PHASE, 'utf-8'); + + test('step 9 checks PLAN.md count on disk when planner return lacks completion marker', () => { + assert.ok( + content.includes('DISK_PLANS=$(ls "${PHASE_DIR}"/*-PLAN.md'), + 'step 9a must check disk for PLAN.md files via DISK_PLANS variable' + ); + }); + + test('step 9a fallback section exists', () => { + assert.ok( + content.includes('## 9a. Filesystem Fallback'), + 'plan-phase.md must have a ## 9a. Filesystem Fallback section for planner hang recovery' + ); + }); + + test('step 9a fallback offers Accept plans option', () => { + assert.ok( + content.includes('Accept plans'), + 'step 9a must offer "Accept plans" as a recovery option' + ); + }); + + test('step 9a fallback offers Retry planner option', () => { + assert.ok( + content.includes('Retry planner'), + 'step 9a must offer "Retry planner" as a recovery option' + ); + }); + + test('step 11 has filesystem fallback section', () => { + assert.ok( + content.includes('## 11a. Filesystem Fallback'), + 'plan-phase.md must have a ## 11a. Filesystem Fallback section for checker hang recovery' + ); + }); + + test('step 11a fallback offers Accept verification option', () => { + assert.ok( + content.includes('Accept verification'), + 'step 11a must offer "Accept verification" as a recovery option' + ); + }); + + test('step 11a fallback offers Retry checker option', () => { + assert.ok( + content.includes('Retry checker'), + 'step 11a must offer "Retry checker" as a recovery option' + ); + }); + + test('step 9 routes to step 9a when no recognized marker found', () => { + assert.ok( + content.includes('step 9a') || content.includes('9a.'), + 'step 9 handle-return must reference the filesystem fallback path (step 9a)' + ); + }); + + test('step 11 routes to step 11a when no recognized marker found', () => { + assert.ok( + content.includes('step 11a') || content.includes('11a.'), + 'step 11 handle-return must reference the filesystem fallback path (step 11a)' + ); + }); +}); + +describe('plan-phase.md — chunked mode flag and config (#2310)', () => { + const content = fs.readFileSync(PLAN_PHASE, 'utf-8'); + + test('step 2 parses --chunked flag', () => { + assert.ok( + content.includes('--chunked'), + 'step 2 must parse --chunked flag from $ARGUMENTS' + ); + }); + + test('step 2 reads workflow.plan_chunked config', () => { + assert.ok( + content.includes('workflow.plan_chunked'), + 'step 2 must read workflow.plan_chunked config key' + ); + }); + + test('step 2 sets CHUNKED_MODE variable', () => { + assert.ok( + content.includes('CHUNKED_MODE'), + 'step 2 must set CHUNKED_MODE from flag or config' + ); + }); +}); + +describe('plan-phase.md — chunked mode implementation (#2310)', () => { + const content = fs.readFileSync(PLAN_PHASE, 'utf-8'); + + test('step 8.5 chunked planning section exists', () => { + assert.ok( + content.includes('## 8.5.'), + 'plan-phase.md must have a step 8.5 section for chunked planning mode' + ); + }); + + test('chunked mode produces PLAN-OUTLINE.md', () => { + assert.ok( + content.includes('PLAN-OUTLINE.md'), + 'chunked mode outline step must produce a *-PLAN-OUTLINE.md file' + ); + }); + + test('chunked outline step uses outline-only mode', () => { + assert.ok( + content.includes('outline-only'), + 'chunked step 8.5.1 must spawn the planner in outline-only mode' + ); + }); + + test('chunked per-plan step uses single-plan mode', () => { + assert.ok( + content.includes('single-plan'), + 'chunked step 8.5.2 must spawn the planner in single-plan mode for each plan' + ); + }); + + test('chunked mode checks for existing outline to enable resume', () => { + // The resume check skips the outline Task if PLAN-OUTLINE.md already exists + assert.ok( + content.includes('PLAN-OUTLINE.md') && content.includes('already exists'), + 'chunked mode must detect existing PLAN-OUTLINE.md and skip outline generation (resume safety)' + ); + }); + + test('chunked mode commits each plan individually', () => { + assert.ok( + content.includes('chunked'), + 'chunked mode must commit each individual plan for crash resilience' + ); + }); + + test('step 8 routes to chunked path when CHUNKED_MODE is true', () => { + assert.ok( + content.includes('CHUNKED_MODE') && content.includes('8.5'), + 'step 8 must route to step 8.5 when CHUNKED_MODE is true' + ); + }); +}); + +describe('gsd-planner.md — references planner-chunked.md (#2310)', () => { + const plannerContent = fs.readFileSync(PLANNER_AGENT, 'utf-8'); + + test('gsd-planner.md references planner-chunked.md for chunked return formats', () => { + assert.ok( + plannerContent.includes('planner-chunked.md'), + 'gsd-planner.md must reference planner-chunked.md for ## OUTLINE COMPLETE / ## PLAN COMPLETE formats' + ); + }); +}); + +describe('planner-chunked.md — chunked return formats (#2310)', () => { + const content = fs.readFileSync(PLANNER_CHUNKED_REF, 'utf-8'); + + test('planner-chunked.md defines OUTLINE COMPLETE structured return', () => { + assert.ok( + content.includes('## OUTLINE COMPLETE'), + 'planner-chunked.md must define ## OUTLINE COMPLETE return format for outline-only mode' + ); + }); + + test('planner-chunked.md defines PLAN COMPLETE structured return for single-plan mode', () => { + assert.ok( + content.includes('## PLAN COMPLETE'), + 'planner-chunked.md must define ## PLAN COMPLETE return format for single-plan mode' + ); + }); + + test('planner-chunked.md describes resume behaviour', () => { + assert.ok( + content.includes('Resume') || content.includes('resume'), + 'planner-chunked.md must describe resume behaviour for interrupted chunked runs' + ); + }); +}); + +describe('config-schema.cjs — workflow.plan_chunked key (#2310)', () => { + test('VALID_CONFIG_KEYS includes workflow.plan_chunked', () => { + const { VALID_CONFIG_KEYS } = require(CONFIG_SCHEMA); + assert.ok( + VALID_CONFIG_KEYS.has('workflow.plan_chunked'), + 'config-schema.cjs VALID_CONFIG_KEYS must include workflow.plan_chunked' + ); + }); +}); + +describe('docs/CONFIGURATION.md — workflow.plan_chunked documented (#2310)', () => { + const content = fs.readFileSync(CONFIGURATION_MD, 'utf-8'); + + test('CONFIGURATION.md documents workflow.plan_chunked', () => { + assert.ok( + content.includes('`workflow.plan_chunked`'), + 'docs/CONFIGURATION.md must document workflow.plan_chunked' + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-3209-plan-phase-ingest-adr.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-3209-plan-phase-ingest-adr (consolidation epic #1969 B3 #1972)", () => { +// allow-test-rule: source-text-is-the-product (see #3209) +// These assertions validate shipped workflow/command markdown contracts. + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.join(__dirname, '..'); +const COMMAND_PATH = path.join(ROOT, 'commands', 'gsd', 'plan-phase.md'); +const WORKFLOW_PATH = path.join(ROOT, 'gsd-core', 'workflows', 'plan-phase.md'); +const DOCS_COMMANDS_PATH = path.join(ROOT, 'docs', 'COMMANDS.md'); + +function read(filePath) { + return fs.readFileSync(filePath, 'utf8'); +} + +describe('enh #3209: plan-phase ADR ingest express path', () => { + test('command argument-hint advertises --ingest and --ingest-format', () => { + const command = read(COMMAND_PATH); + assert.ok(command.includes('--ingest '), + 'plan-phase command argument-hint must include --ingest '); + assert.ok(command.includes('--ingest-format '), + 'plan-phase command argument-hint must include --ingest-format selector'); + }); + + test('workflow parses --ingest and --ingest-format flags', () => { + const workflow = read(WORKFLOW_PATH); + assert.ok(workflow.includes('--ingest '), + 'plan-phase workflow argument parsing must mention --ingest'); + assert.ok(workflow.includes('--ingest-format'), + 'plan-phase workflow argument parsing must mention --ingest-format'); + }); + + test('workflow has explicit mutual exclusion guard for --prd and --ingest', () => { + const workflow = read(WORKFLOW_PATH); + assert.ok( + workflow.includes('cannot combine `--prd` with `--ingest`') || + workflow.includes('mutually exclusive'), + 'plan-phase workflow must fail fast when --prd and --ingest are both provided' + ); + }); + + test('workflow defines an ADR ingest express-path step', () => { + const workflow = read(WORKFLOW_PATH); + assert.ok(/##\s*(?:\d+(?:\.\d+)*)?\.?\s*Handle ADR Ingest Express Path/i.test(workflow), + 'plan-phase workflow must include a dedicated ADR ingest express-path step'); + assert.ok(workflow.includes('ADR Ingest Express Path'), + 'workflow must display ADR ingest express-path banner text'); + }); + + test('ADR ingest context template includes scope fence and ADR source attribution', () => { + const workflow = read(WORKFLOW_PATH); + assert.ok(workflow.includes(''), + 'ADR ingest context template must include for hard out-of-scope exclusions'); + assert.ok(workflow.includes('Source:** ADR Ingest Express Path'), + 'ADR ingest context template must tag source as ADR Ingest Express Path'); + }); + + test('workflow documents status gate and no-decisions fallback', () => { + const workflow = read(WORKFLOW_PATH); + assert.ok( + workflow.includes('Reject `superseded`/`rejected`/`deprecated`') || + workflow.includes('reject `superseded`/`rejected`/`deprecated`') || + /superseded.*rejected.*deprecated/i.test(workflow), + 'ADR ingest workflow must include status gate for non-active ADRs' + ); + assert.ok( + workflow.includes('empty-decisions fallback') || + workflow.includes('fall back to discuss-phase'), + 'ADR ingest workflow must document fallback when no locked decisions are present' + ); + }); + + test('docs COMMANDS advertises --ingest flag for /gsd-plan-phase', () => { + const commands = read(DOCS_COMMANDS_PATH); + assert.ok(commands.includes('--ingest '), + 'docs/COMMANDS.md must document --ingest for /gsd-plan-phase'); + }); +}); + }); +} diff --git a/tests/planning-workspace.test.cjs b/tests/planning-workspace.test.cjs index 4e3cbd850..64dd644cd 100644 --- a/tests/planning-workspace.test.cjs +++ b/tests/planning-workspace.test.cjs @@ -360,3 +360,225 @@ describe('withPlanningLock PID-liveness staleness + EEXIST safety (audit M1+M2)' assert.ok(!fs.existsSync(lockPath), 'lock must be released after the critical section completes'); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3739-gap-checker-padded-prefix-context.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3739-gap-checker-padded-prefix-context (consolidation epic #1969 B3 #1972)", () => { +/** + * Bug #3739: gap-analysis silently skips CONTEXT.md decisions when the file + * uses the padded-prefix convention (e.g. 01-CONTEXT.md, 02.1-CONTEXT.md). + * + * Verifies: + * 1. Padded-prefix CONTEXT.md (NN-CONTEXT.md) decisions ARE included in the + * gap report — was silently skipped before the fix. + * 2. Decisions from padded-prefix CONTEXT.md ARE checked for coverage. + * 3. Bare CONTEXT.md still works — no regression on the existing path. + * 4. A padded-prefix decision that is NOT covered in the plan is surfaced + * as "Not covered" (not silently dropped from the report). + * 5. planning-workspace.cjs findContextMdIn() helper returns the right + * filename for both bare and padded forms (unit test for the extractor). + */ + +'use strict'; + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); + +describe('bug #3739 — gap-analysis padded-prefix CONTEXT.md', () => { + let tmpDir; + let phaseDir; + + function writeContextAs(filename, decisions) { + const dLines = decisions.map(d => `- **${d.id}:** ${d.text}`).join('\n'); + fs.writeFileSync( + path.join(phaseDir, filename), + `# Phase Context\n\n\n## Implementation Decisions\n\n${dLines}\n\n` + ); + } + + function writePlan(name, body) { + fs.writeFileSync(path.join(phaseDir, `${name}-PLAN.md`), body); + } + + function ensureConfig() { + const r = runGsdTools('config-ensure-section', tmpDir); + assert.ok(r.success, `config-ensure-section failed: ${r.error}`); + } + + beforeEach(() => { + tmpDir = createTempProject(); + phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test'); + fs.mkdirSync(phaseDir, { recursive: true }); + ensureConfig(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + // ── Test 1: padded-prefix decisions appear in the gap report ───────────── + + test('decisions from padded-prefix CONTEXT.md (01-CONTEXT.md) appear in gap report', () => { + writeContextAs('01-CONTEXT.md', [ + { id: 'D-01', text: 'Use library X' }, + { id: 'D-02', text: 'Fail loud on unknown input' }, + ]); + writePlan('01', '# Plan\n\nImplements D-01 and D-02.\n'); + + const r = runGsdTools(['gap-analysis', '--phase-dir', phaseDir], tmpDir); + assert.ok(r.success, `gap-analysis failed: ${r.error}`); + const out = JSON.parse(r.output); + + const d01 = out.rows.find(x => x.item === 'D-01'); + const d02 = out.rows.find(x => x.item === 'D-02'); + + assert.ok(d01, 'D-01 row must appear in gap report when CONTEXT.md uses padded-prefix 01-CONTEXT.md'); + assert.ok(d02, 'D-02 row must appear in gap report when CONTEXT.md uses padded-prefix 01-CONTEXT.md'); + assert.strictEqual(d01.source, 'CONTEXT.md', 'source label must be CONTEXT.md'); + assert.strictEqual(d01.status, 'Covered', 'D-01 is mentioned in plan — must be Covered'); + assert.strictEqual(d02.status, 'Covered', 'D-02 is mentioned in plan — must be Covered'); + }); + + // ── Test 2: uncovered padded-prefix decision surfaces as Not covered ────── + + test('uncovered decision from padded-prefix CONTEXT.md surfaces as Not covered', () => { + writeContextAs('01-CONTEXT.md', [ + { id: 'D-01', text: 'Use library X' }, + ]); + writePlan('01', '# Plan\n\nUnrelated work, no mention of any D-NN.\n'); + + const r = runGsdTools(['gap-analysis', '--phase-dir', phaseDir], tmpDir); + assert.ok(r.success, `gap-analysis failed: ${r.error}`); + const out = JSON.parse(r.output); + + const d01 = out.rows.find(x => x.item === 'D-01'); + assert.ok(d01, 'D-01 row must appear even when not covered'); + assert.strictEqual(d01.status, 'Not covered', + 'D-01 must be Not covered (not silently absent) when plan omits it'); + }); + + // ── Test 3 (counter-test): bare CONTEXT.md still works — no regression ─── + + test('bare CONTEXT.md still works (regression guard)', () => { + writeContextAs('CONTEXT.md', [ + { id: 'D-05', text: 'Bare form decision' }, + ]); + writePlan('01', '# Plan\n\nImplements D-05.\n'); + + const r = runGsdTools(['gap-analysis', '--phase-dir', phaseDir], tmpDir); + assert.ok(r.success, `gap-analysis failed: ${r.error}`); + const out = JSON.parse(r.output); + + const d05 = out.rows.find(x => x.item === 'D-05'); + assert.ok(d05, 'D-05 must appear when CONTEXT.md uses bare filename'); + assert.strictEqual(d05.status, 'Covered', 'D-05 must be Covered'); + }); + + // ── Test 4: deeper padded prefix (02.1-CONTEXT.md) ─────────────────────── + + test('multi-segment padded prefix (02.1-CONTEXT.md) decisions appear in gap report', () => { + writeContextAs('02.1-CONTEXT.md', [ + { id: 'D-03', text: 'Use postgres' }, + ]); + writePlan('01', '# Plan\n\nImplements D-03.\n'); + + const r = runGsdTools(['gap-analysis', '--phase-dir', phaseDir], tmpDir); + assert.ok(r.success, `gap-analysis failed: ${r.error}`); + const out = JSON.parse(r.output); + + const d03 = out.rows.find(x => x.item === 'D-03'); + assert.ok(d03, 'D-03 must appear from 02.1-CONTEXT.md'); + assert.strictEqual(d03.status, 'Covered'); + }); + + // ── Test 5: findContextMdIn helper unit test ───────────────────────────── + + test('findContextMdIn helper returns padded filename when present', () => { + const { findContextMdIn } = require('../gsd-core/bin/lib/planning-workspace.cjs'); + // Write 01-CONTEXT.md into the phase dir (already created in beforeEach) + fs.writeFileSync(path.join(phaseDir, '01-CONTEXT.md'), '# context\n'); + + const found = findContextMdIn(phaseDir); + assert.strictEqual(found, '01-CONTEXT.md', + 'findContextMdIn must return the padded-prefix filename'); + }); + + test('findContextMdIn helper returns bare filename when only bare form exists', () => { + const { findContextMdIn } = require('../gsd-core/bin/lib/planning-workspace.cjs'); + fs.writeFileSync(path.join(phaseDir, 'CONTEXT.md'), '# context\n'); + + const found = findContextMdIn(phaseDir); + assert.strictEqual(found, 'CONTEXT.md', + 'findContextMdIn must return CONTEXT.md for bare form'); + }); + + test('findContextMdIn helper returns null when no CONTEXT.md exists', () => { + const { findContextMdIn } = require('../gsd-core/bin/lib/planning-workspace.cjs'); + // phaseDir exists but is empty (no CONTEXT.md) + const found = findContextMdIn(phaseDir); + assert.strictEqual(found, null, + 'findContextMdIn must return null when no CONTEXT.md exists'); + }); + + // ── Test 5b: findContextMdIn accepts pre-read files array (avoids double readdirSync) ── + + test('findContextMdIn accepts an already-read files array (avoids double readdirSync)', () => { + const { findContextMdIn } = require('../gsd-core/bin/lib/planning-workspace.cjs'); + // Passing an array directly should behave identically to passing a directory path. + assert.strictEqual(findContextMdIn(['CONTEXT.md', 'other.md']), 'CONTEXT.md', + 'bare form found in array'); + assert.strictEqual(findContextMdIn(['01-CONTEXT.md', 'other.md']), '01-CONTEXT.md', + 'padded form found in array'); + assert.strictEqual(findContextMdIn(['unrelated.md']), null, + 'returns null when no CONTEXT.md in array'); + // Bare wins over padded when both are present + assert.strictEqual(findContextMdIn(['01-CONTEXT.md', 'CONTEXT.md']), 'CONTEXT.md', + 'bare form preferred over padded form when both in array'); + }); + + // ── Test 6: dual-file precedence — bare CONTEXT.md wins over padded form ── + + test('findContextMdIn prefers bare CONTEXT.md over padded form (helper level)', () => { + const { findContextMdIn } = require('../gsd-core/bin/lib/planning-workspace.cjs'); + // Write BOTH forms into the phase directory + fs.writeFileSync(path.join(phaseDir, 'CONTEXT.md'), '# bare context\n'); + fs.writeFileSync(path.join(phaseDir, '01-CONTEXT.md'), '# padded context\n'); + + const found = findContextMdIn(phaseDir); + assert.strictEqual(found, 'CONTEXT.md', + 'findContextMdIn must return bare CONTEXT.md when both forms exist — matches pre-refactor gap-checker behavior'); + }); + + test('gap-analysis uses bare CONTEXT.md decisions when both forms exist (integration level)', () => { + // Bare form has D-BARE; padded form has D-PADDED. + // If the integration path resolves bare correctly, only D-BARE appears in the report. + const bareContent = + '# Phase Context\n\n\n## Implementation Decisions\n\n- **D-BARE:** From bare form\n\n'; + const paddedContent = + '# Phase Context\n\n\n## Implementation Decisions\n\n- **D-PADDED:** From padded form\n\n'; + fs.writeFileSync(path.join(phaseDir, 'CONTEXT.md'), bareContent); + fs.writeFileSync(path.join(phaseDir, '01-CONTEXT.md'), paddedContent); + + writePlan('01', '# Plan\n\nImplements D-BARE.\n'); + + const r = runGsdTools(['gap-analysis', '--phase-dir', phaseDir], tmpDir); + assert.ok(r.success, `gap-analysis failed: ${r.error}`); + const out = JSON.parse(r.output); + + const dBare = out.rows.find(x => x.item === 'D-BARE'); + const dPadded = out.rows.find(x => x.item === 'D-PADDED'); + + assert.ok(dBare, 'D-BARE (from bare CONTEXT.md) must appear in gap report'); + assert.ok(!dPadded, 'D-PADDED (from 01-CONTEXT.md) must NOT appear — bare form takes precedence'); + assert.strictEqual(dBare.status, 'Covered', 'D-BARE must be Covered'); + }); +}); + }); +} diff --git a/tests/profile-output.test.cjs b/tests/profile-output.test.cjs index 4d592655f..c848e126b 100644 --- a/tests/profile-output.test.cjs +++ b/tests/profile-output.test.cjs @@ -376,3 +376,126 @@ describe('generate-dev-preferences command', () => { assert.strictEqual(out.command_path, path.join(clineHome, 'skills', 'gsd-dev-preferences', 'SKILL.md')); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-2415-claude-md-link-mode.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-2415-claude-md-link-mode (consolidation epic #1969 B3 #1972)", () => { +'use strict'; + +// allow-test-rule: source-text-is-the-product (see #2415) +// Reads .md/.json/.yml product files whose deployed text IS what the +// runtime loads — testing text content tests the deployed contract. + +/** + * Tests for claude_md_assembly "link" mode (#2415). + * Verifies that generate-claude-md writes @-references instead of inlined + * content when claude_md_assembly.mode is "link". + */ + +const { test, after } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const helpers = require('./helpers.cjs'); + +const { cmdGenerateClaudeMd } = require('../gsd-core/bin/lib/profile-output.cjs'); + +const _dirsToClean = []; +after(() => { for (const d of _dirsToClean) helpers.cleanup(d); }); + +function makeTempProject(files = {}) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2415-')); + _dirsToClean.push(dir); + fs.mkdirSync(path.join(dir, '.planning', 'codebase'), { recursive: true }); + for (const [rel, content] of Object.entries(files)) { + const abs = path.join(dir, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, content, 'utf-8'); + } + return dir; +} + +test('link mode writes @-reference for architecture section', () => { + const dir = makeTempProject({ + '.planning/codebase/ARCHITECTURE.md': '# Architecture\n\n- layered\n', + '.planning/config.json': JSON.stringify({ claude_md_assembly: { mode: 'link' } }), + }); + + cmdGenerateClaudeMd(dir, { output: path.join(dir, 'CLAUDE.md') }, false); + + const content = fs.readFileSync(path.join(dir, 'CLAUDE.md'), 'utf-8'); + assert.ok(content.includes('@.planning/codebase/ARCHITECTURE.md'), 'should contain @-reference'); + assert.ok(!content.includes('- layered'), 'should not inline architecture content'); +}); + +test('link mode writes @-reference for project section', () => { + const dir = makeTempProject({ + '.planning/PROJECT.md': '# My Project\n\n## What This Is\n\nA great app.\n', + '.planning/config.json': JSON.stringify({ claude_md_assembly: { mode: 'link' } }), + }); + + cmdGenerateClaudeMd(dir, { output: path.join(dir, 'CLAUDE.md') }, false); + + const content = fs.readFileSync(path.join(dir, 'CLAUDE.md'), 'utf-8'); + assert.ok(content.includes('@.planning/PROJECT.md'), 'should contain @-reference for project'); + assert.ok(!content.includes('A great app.'), 'should not inline project content'); +}); + +test('embed mode (default) inlines content as before', () => { + const dir = makeTempProject({ + '.planning/codebase/ARCHITECTURE.md': '# Architecture\n\n- monolith\n', + }); + + cmdGenerateClaudeMd(dir, { output: path.join(dir, 'CLAUDE.md') }, false); + + const content = fs.readFileSync(path.join(dir, 'CLAUDE.md'), 'utf-8'); + assert.ok(content.includes('- monolith'), 'embed mode should inline content'); + assert.ok(!content.includes('@.planning/codebase/ARCHITECTURE.md'), 'embed mode should not write @-reference'); +}); + +test('per-block override: link only architecture, embed others', () => { + const dir = makeTempProject({ + '.planning/PROJECT.md': '# Proj\n\n## What This Is\n\nApp.\n', + '.planning/codebase/ARCHITECTURE.md': '# Arch\n\n- layers\n', + '.planning/config.json': JSON.stringify({ claude_md_assembly: { mode: 'embed', blocks: { architecture: 'link' } } }), + }); + + cmdGenerateClaudeMd(dir, { output: path.join(dir, 'CLAUDE.md') }, false); + + const content = fs.readFileSync(path.join(dir, 'CLAUDE.md'), 'utf-8'); + assert.ok(content.includes('@.planning/codebase/ARCHITECTURE.md'), 'architecture should use link'); + assert.ok(!content.includes('@.planning/PROJECT.md'), 'project should use embed'); + assert.ok(content.includes('App.'), 'project content should be inlined'); +}); + +test('link mode falls back to embed for workflow section (no linkable source)', () => { + const dir = makeTempProject({ + '.planning/config.json': JSON.stringify({ claude_md_assembly: { mode: 'link' } }), + }); + + cmdGenerateClaudeMd(dir, { output: path.join(dir, 'CLAUDE.md') }, false); + + const content = fs.readFileSync(path.join(dir, 'CLAUDE.md'), 'utf-8'); + // workflow section should still be inlined (it has no linkPath) + assert.ok(!content.includes('@GSD defaults'), 'workflow should not write @GSD defaults'); + assert.ok(content.includes('GSD Workflow Enforcement'), 'workflow content should be embedded inline'); +}); + +test('link mode falls back to embed when source file is missing (hasFallback)', () => { + const dir = makeTempProject({ + '.planning/config.json': JSON.stringify({ claude_md_assembly: { mode: 'link' } }), + }); + // No .planning/codebase/ARCHITECTURE.md — generator will use fallback + + cmdGenerateClaudeMd(dir, { output: path.join(dir, 'CLAUDE.md') }, false); + + const content = fs.readFileSync(path.join(dir, 'CLAUDE.md'), 'utf-8'); + assert.ok(!content.includes('@.planning/codebase/ARCHITECTURE.md'), 'fallback section should not write @-reference'); +}); + }); +} diff --git a/tests/progress-forensic.test.cjs b/tests/progress-forensic.test.cjs index 87fe3a85a..002a2d399 100644 --- a/tests/progress-forensic.test.cjs +++ b/tests/progress-forensic.test.cjs @@ -241,3 +241,46 @@ describe('#1107: progress routing consults verification.status before reporting 'missing or unknown verification must not be documented as complete'); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3418-progress-flag-routing.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3418-progress-flag-routing (consolidation epic #1969 B3 #1972)", () => { +// allow-test-rule: source-text-is-the-product (see #3418) +// The command markdown is loaded directly by runtime prompt assembly. + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +describe('#3418: /gsd-progress flag routing prompt contract', () => { + test('progress command surfaces raw arguments on a dedicated line before routing parse', () => { + const command = fs.readFileSync( + path.join(__dirname, '..', 'commands', 'gsd', 'progress.md'), + 'utf8' + ); + + assert.ok( + command.includes('Arguments provided: "$ARGUMENTS"'), + 'progress.md must surface $ARGUMENTS on a dedicated line for stable flag parsing' + ); + }); + + test('progress command must not inline-substitute $ARGUMENTS into parse instruction text', () => { + const command = fs.readFileSync( + path.join(__dirname, '..', 'commands', 'gsd', 'progress.md'), + 'utf8' + ); + + assert.ok( + !command.includes('Parse the first token of $ARGUMENTS:'), + 'progress.md must keep parse instructions independent from argument interpolation' + ); + }); +}); + }); +} diff --git a/tests/progress-mvp-display.test.cjs b/tests/progress-mvp-display.test.cjs index 8dd063c88..a360b6361 100644 --- a/tests/progress-mvp-display.test.cjs +++ b/tests/progress-mvp-display.test.cjs @@ -40,3 +40,86 @@ describe('progress — MVP mode display', () => { assert.ok(contract.hasStandardFallback, 'must specify fallback when mode is not mvp'); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-14-progress-auto-flag-dropped.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-14-progress-auto-flag-dropped (consolidation epic #1969 B3 #1972)", () => { +// allow-test-rule: source-text-is-the-product (see #14) +// The command markdown is loaded directly by runtime prompt assembly. +// This test verifies that --auto is documented in progress.md and handled in next.md. + +'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, '..'); + +describe('#14: /gsd:progress --next --auto flag must be documented and propagated', () => { + test('progress.md section documents --auto flag', () => { + const command = fs.readFileSync( + path.join(ROOT, 'commands', 'gsd', 'progress.md'), + 'utf8' + ); + + assert.ok( + command.includes('--auto'), + 'progress.md must document the --auto flag in the section' + ); + }); + + test('progress.md block explicitly passes --auto through to next workflow', () => { + const command = fs.readFileSync( + path.join(ROOT, 'commands', 'gsd', 'progress.md'), + 'utf8' + ); + + // Extract only the … block so this assertion is + // scoped to the handoff wiring, not just any occurrence in the file. + const processMatch = command.match(/([\s\S]*?)<\/process>/); + assert.ok( + processMatch, + 'progress.md must contain a block' + ); + const processBlock = processMatch[1]; + + assert.ok( + processBlock.includes('--auto'), + 'progress.md block must explicitly mention --auto so it is not silently stripped at the --next handoff' + ); + }); + + test('next.md show_and_execute step handles --auto to chain steps', () => { + const workflow = fs.readFileSync( + path.join(ROOT, 'gsd-core', 'workflows', 'next.md'), + 'utf8' + ); + + assert.ok( + workflow.includes('--auto'), + 'next.md must handle the --auto flag to chain step invocations automatically' + ); + }); + + test('next.md --auto chaining re-invokes /gsd:progress --next after step completion', () => { + const workflow = fs.readFileSync( + path.join(ROOT, 'gsd-core', 'workflows', 'next.md'), + 'utf8' + ); + + // The workflow must contain instructions to re-invoke /gsd:progress --next --auto + // after the determined step completes, enabling the chain. + assert.ok( + workflow.includes('--next --auto'), + 'next.md must instruct re-invocation of /gsd:progress --next --auto after step completion to enable chaining' + ); + }); +}); + }); +} diff --git a/tests/reapply-patches.test.cjs b/tests/reapply-patches.test.cjs index 2b85c4bae..317be38a8 100644 --- a/tests/reapply-patches.test.cjs +++ b/tests/reapply-patches.test.cjs @@ -415,3 +415,102 @@ describe('reapply-patches gated hunk verification (#1999)', () => { ); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2424-reapply-patches-baseline-detection.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2424-reapply-patches-baseline-detection (consolidation epic #1969 B3 #1972)", () => { +/** + * Bug #2424: reapply-patches pristine-baseline detection uses first-add commit + * + * The three-way merge baseline detection previously used `git log --diff-filter=A` + * which returns the commit that FIRST added the file. On repos that have been + * through multiple GSD update cycles, this returns a stale, many-versions-old + * baseline — not the version immediately prior to the current update. + * + * Fix: Option A must prefer `pristine_hashes` from backup-meta.json to locate + * the correct baseline commit by SHA-256 matching, with a fallback to the + * first-add heuristic only when no pristine hash is recorded. + * + * #2790: reapply-patches.md (which contained the inline Option A / Option B workflow) + * was consolidated into update.md as the --reapply flag. The behavioral contract + * (pristine_hashes preference, fallback to first-add) is maintained in the + * update.md workflow's --reapply path. These tests now verify the consolidation. + */ + +// allow-test-rule: source-text-is-the-product (see #2424) +// gsd-core/workflows/update.md is the installed runtime workflow — +// its text IS the deployed behavioral contract. + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +// #2790: reapply-patches.md (command with inline workflow) was deleted. +// The --reapply functionality is now in update.md. +const UPDATE_MD = path.join(__dirname, '..', 'commands', 'gsd', 'update.md'); + +/** + * Parse a field from YAML frontmatter between --- markers. + * Returns null if the frontmatter or field is absent. + */ +function parseFrontmatterField(content, field) { + const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!fmMatch) return null; + const fm = fmMatch[1]; + const quoted = fm.match(new RegExp(`^${field}:\\s+"((?:[^"\\\\]|\\\\.)*)"\\s*$`, 'm')); + if (quoted) return quoted[1]; + const plain = fm.match(new RegExp(`^${field}:\\s+(.+)$`, 'm')); + if (plain) return plain[1].trim(); + return null; +} + +describe('reapply-patches pristine baseline detection (#2424)', () => { + test('reapply-patches.md command is deleted (absorbed into update.md --reapply, #2790)', () => { + const oldPath = path.join(__dirname, '..', 'commands', 'gsd', 'reapply-patches.md'); + assert.ok(!fs.existsSync(oldPath), 'reapply-patches.md should be absent (absorbed into update.md --reapply)'); + }); + + test('update.md argument-hint declares --reapply as consolidated entry point', () => { + const content = fs.readFileSync(UPDATE_MD, 'utf-8'); + const argHint = parseFrontmatterField(content, 'argument-hint'); + assert.ok( + argHint && argHint.includes('--reapply'), + `update.md argument-hint must declare --reapply flag; got: ${argHint || '(none)'}` + ); + }); + + test('update.md workflow references backup-meta.json for pristine-hash baseline', () => { + // #2790: The behavioral contract (pristine_hashes from backup-meta.json as primary + // baseline source) is implemented in the update.md workflow (gsd-core/workflows/update.md), + // not the command file. The command delegates via --reapply flag. + // Verify the underlying workflow has this content. + const workflowPath = path.join(__dirname, '..', 'gsd-core', 'workflows', 'update.md'); + const workflowContent = fs.readFileSync(workflowPath, 'utf-8'); + assert.ok( + workflowContent.includes('backup-meta.json'), + 'gsd-core/workflows/update.md must reference backup-meta.json (pristine_hashes baseline source)' + ); + }); + + test('update.md exists as consolidated entry point', () => { + assert.ok(fs.existsSync(UPDATE_MD), 'update.md must exist as consolidated entry point'); + }); + + test('update.md argument-hint declares full consolidated flag surface (--sync | --reapply)', () => { + // Validates that both flags absorbed from the deleted micro-skills are declared + // in the command contract, not just --reapply alone. + const content = fs.readFileSync(UPDATE_MD, 'utf-8'); + const argHint = parseFrontmatterField(content, 'argument-hint'); + assert.ok( + argHint && argHint.includes('--sync') && argHint.includes('--reapply'), + `update.md argument-hint must declare both --sync and --reapply; got: ${argHint || '(none)'}` + ); + }); +}); + }); +} diff --git a/tests/roadmap-parser.test.cjs b/tests/roadmap-parser.test.cjs index b423447b1..f35472439 100644 --- a/tests/roadmap-parser.test.cjs +++ b/tests/roadmap-parser.test.cjs @@ -608,3 +608,952 @@ describe('roadmap-parser: getMilestonePhaseFilter', () => { assert.strictEqual(filter('02-02-build'), true, 'bracket-prefixed phase 2-02 matched'); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2554-decimal-phase-filter.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2554-decimal-phase-filter (consolidation epic #1969 B3 #1972)", () => { +/** + * Regression test for bug #2554: + * state disk-scan excludes decimal phase dirs (e.g. "00.1") from progress counts. + * + * Root cause: getMilestonePhaseFilter normalized phase IDs with `replace(/^0+/, '')`, + * which over-strips on decimals: "00.1" → ".1", while the disk-side extractor + * applied to "00.1-" yields "0.1" — so the dir is excluded from the milestone. + * + * Fix: strip leading zeros only when followed by a digit (`replace(/^0+(?=\d)/, '')`), + * preserving the zero before the decimal point. + */ + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const { createTempProject, cleanup } = require('./helpers.cjs'); +const { getMilestonePhaseFilter } = require('../gsd-core/bin/lib/roadmap-parser.cjs'); + +describe('bug #2554 — getMilestonePhaseFilter decimal phase dirs', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('matches decimal phase directory like "00.1-" against ROADMAP phase "00.1"', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + [ + '## Roadmap v1.0: Current', + '', + '### Phase 0: Foundation', + '**Goal:** foundation', + '', + '### Phase 00.1: Inserted urgent work', + '**Goal:** inserted', + '', + '### Phase 1: Feature', + '**Goal:** feature', + ].join('\n') + ); + + const filter = getMilestonePhaseFilter(tmpDir); + + // Phase 00.1 inserted between Phase 0 and Phase 1 must match its on-disk dir. + assert.strictEqual( + filter('00.1-app-namespace-rename'), + true, + 'decimal phase dir "00.1-" must be counted in the milestone' + ); + + // Neighbours should still match (no regression). + assert.strictEqual(filter('0-foundation'), true); + assert.strictEqual(filter('1-feature'), true); + }); + + test('preserves existing behavior for zero-padded integer phases', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + [ + '## Roadmap v1.0: Current', + '', + '### Phase 01: One', + '**Goal:** g', + '', + '### Phase 10: Ten', + '**Goal:** g', + ].join('\n') + ); + + const filter = getMilestonePhaseFilter(tmpDir); + assert.strictEqual(filter('01-one'), true); + assert.strictEqual(filter('10-ten'), true); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-730-milestone-phase-details-scope.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-730-milestone-phase-details-scope (consolidation epic #1969 B3 #1972)", () => { +/** + * Regression test for bug #730: phase details defined under a milestone-scoped + * "## Milestone vX.Y — … (Phase Details)" section are invisible to phase + * resolution (getRoadmapPhaseInternal / init phase-op) when the flat shared + * "## Phase Details" section for an earlier milestone sits between the shared + * ## Phases checklist and the per-milestone Phase Details section. + * + * The bug manifests ONLY before any .planning/phases/ directory exists because + * findPhaseInternal masks it once the dir is created. RED step — tests 1 and 3 + * are expected to fail against current code. + */ + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { runGsdTools, cleanup } = require('./helpers.cjs'); + +// --------------------------------------------------------------------------- +// Shared fixture content +// --------------------------------------------------------------------------- + +const STATE_CONTENT = `--- +milestone: v1.1 +--- +`; + +const ROADMAP_CONTENT = `# Roadmap: Example + +## Phases + +- [x] **Phase 1: Setup** — initial scaffold + +### Milestone v1.1 — Second milestone (added 2026-01-01) + +- [ ] **Phase 2: Feature** — the new thing + +## Phase Details + +### Phase 1: Setup +**Goal:** scaffold the app. + +## Milestone v1.1 — Second milestone (Phase Details) + +### Phase 2: Feature +**Goal:** build the new thing. +`; + +// --------------------------------------------------------------------------- +// Helper: create a bare project with .planning/ but NO .planning/phases/ dir +// --------------------------------------------------------------------------- + +function createBareProject() { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-730-')); + fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true }); + return tmpDir; +} + +// --------------------------------------------------------------------------- +// Suite +// --------------------------------------------------------------------------- + +describe('bug #730 — milestone (Phase Details) section scope resolution', () => { + let dir; + + beforeEach(() => { + dir = createBareProject(); + fs.writeFileSync(path.join(dir, '.planning', 'STATE.md'), STATE_CONTENT, 'utf-8'); + fs.writeFileSync(path.join(dir, '.planning', 'ROADMAP.md'), ROADMAP_CONTENT, 'utf-8'); + }); + + afterEach(() => { + cleanup(dir); + }); + + // ------------------------------------------------------------------------- + // Test 1 (AC1): init phase-op resolves phase defined only under its + // per-milestone "(Phase Details)" section + // ------------------------------------------------------------------------- + test('init phase-op resolves a current-milestone phase defined only under its (Phase Details) section', () => { + const r = runGsdTools('init phase-op 2', dir); + assert.ok(r.success, `init phase-op 2 failed: ${r.error}`); + + const out = JSON.parse(r.output); + assert.strictEqual(out.phase_found, true, `phase_found should be true; got phase_found=${out.phase_found}, expected_phase_dir=${out.expected_phase_dir}`); + assert.strictEqual(out.phase_name, 'Feature', `phase_name should be 'Feature'; got '${out.phase_name}'`); + assert.strictEqual(out.padded_phase, '02', `padded_phase should be '02'; got '${out.padded_phase}'`); + assert.strictEqual(out.expected_phase_dir, '.planning/phases/02-feature', `expected_phase_dir should be '.planning/phases/02-feature'; got '${out.expected_phase_dir}'`); + }); + + // ------------------------------------------------------------------------- + // Test 2 (AC4): first-milestone phase still resolves via the flat + // "## Phase Details" section — no regression + // ------------------------------------------------------------------------- + test('init phase-op still resolves a first-milestone phase (no regression on flat Phase Details)', () => { + const r = runGsdTools('init phase-op 1', dir); + assert.ok(r.success, `init phase-op 1 failed: ${r.error}`); + + const out = JSON.parse(r.output); + assert.strictEqual(out.phase_found, true, `phase_found should be true for phase 1; got ${out.phase_found}`); + assert.strictEqual(out.phase_name, 'Setup', `phase_name should be 'Setup'; got '${out.phase_name}'`); + }); + + // ------------------------------------------------------------------------- + // Test 3 (AC5): getRoadmapPhaseInternal resolves the current-milestone phase + // directly before any phases/ dir exists + // ------------------------------------------------------------------------- + test('getRoadmapPhaseInternal resolves the current-milestone phase directly before any dir exists', () => { + const { getRoadmapPhaseInternal } = require('../gsd-core/bin/lib/roadmap-parser.cjs'); + + const res = getRoadmapPhaseInternal(dir, '2'); + assert.ok(res !== null && res !== undefined, `getRoadmapPhaseInternal returned null/undefined for phase 2`); + assert.strictEqual(res.found, true, `res.found should be true; got ${JSON.stringify(res)}`); + assert.strictEqual(res.phase_name, 'Feature', `res.phase_name should be 'Feature'; got '${res.phase_name}'`); + }); + + // ------------------------------------------------------------------------- + // Test 4 (AC3): validate health raises W006 for a current-milestone phase + // defined under (Phase Details) with no directory on disk. + // + // Before the fix, extractCurrentMilestone's slice stopped before the + // "## Milestone v1.1 — … (Phase Details)" section, so phase 2's + // "### Phase 2: Feature" header was invisible and W006 was never raised. + // After the fix the slice includes that section and W006 is emitted. + // + // This test uses its OWN local fixture (separate tmpdir) so it does not + // disturb the shared beforeEach/afterEach fixture used by tests 1–3. + // ------------------------------------------------------------------------- + test('validate health raises W006 for a started current-milestone phase defined under (Phase Details) with no directory', () => { + const localDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-730-t4-')); + try { + const planning = path.join(localDir, '.planning'); + fs.mkdirSync(planning, { recursive: true }); + + // STATE.md — milestone: v1.1 + fs.writeFileSync( + path.join(planning, 'STATE.md'), + `---\nmilestone: v1.1\n---\n`, + 'utf-8', + ); + + // ROADMAP.md — phase 2 is [x] (started/complete) so the not-started + // guard does NOT suppress W006. Phase 2's details live exclusively in + // the per-milestone "(Phase Details)" section (the blind-spot pre-fix). + fs.writeFileSync( + path.join(planning, 'ROADMAP.md'), + `# Roadmap: Example\n\n## Phases\n\n- [x] **Phase 1: Setup** — initial scaffold\n\n### Milestone v1.1 — Second milestone (added 2026-01-01)\n\n- [x] **Phase 2: Feature** — the new thing\n\n## Phase Details\n\n### Phase 1: Setup\n**Goal:** scaffold the app.\n\n## Milestone v1.1 — Second milestone (Phase Details)\n\n### Phase 2: Feature\n**Goal:** build the new thing.\n`, + 'utf-8', + ); + + // Create the phase 1 directory so phase 1 does NOT trigger W006. + // Phase 2 has NO directory — that's the missing-dir condition under test. + fs.mkdirSync(path.join(planning, 'phases', '01-setup'), { recursive: true }); + + const result = runGsdTools(['validate', 'health'], localDir); + const payload = JSON.parse(result.output); + const warnings = payload.warnings || []; + + // Find a W006 entry whose message references phase 2 (by number or name). + const w006ForPhase2 = warnings.find( + (w) => + w.code === 'W006' && + (/\b2\b/.test(w.message) || /\b02\b/.test(w.message) || /Feature/i.test(w.message)), + ); + + assert.ok( + w006ForPhase2 != null, + `Expected a W006 warning referencing phase 2 (Feature) — phase 2 is started ([x]) and has no directory on disk, ` + + `but its ### Phase 2: header lives in the Milestone v1.1 (Phase Details) section which was invisible before the fix. ` + + `Got warnings: ${JSON.stringify(warnings)}`, + ); + } finally { + cleanup(localDir); + } + }); + + // ------------------------------------------------------------------------- + // Test 5: three-milestone roadmap, current = latest (v1.2) + // ------------------------------------------------------------------------- + test('init phase-op resolves the latest milestone phase in a 3-milestone roadmap', () => { + const localDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-730-t5-')); + try { + const planning = path.join(localDir, '.planning'); + fs.mkdirSync(planning, { recursive: true }); + + fs.writeFileSync( + path.join(planning, 'STATE.md'), + `---\nmilestone: v1.2\n---\n`, + 'utf-8', + ); + + fs.writeFileSync( + path.join(planning, 'ROADMAP.md'), + `# Roadmap: Example\n\n## Phases\n\n- [x] **Phase 1: Setup** — done\n\n### Milestone v1.1 — Second (added 2026-01-01)\n\n- [x] **Phase 2: Feature** — done\n\n### Milestone v1.2 — Third (added 2026-02-01)\n\n- [ ] **Phase 3: Polish** — current\n\n## Phase Details\n\n### Phase 1: Setup\n**Goal:** scaffold.\n\n## Milestone v1.1 — Second (Phase Details)\n\n### Phase 2: Feature\n**Goal:** build.\n\n## Milestone v1.2 — Third (Phase Details)\n\n### Phase 3: Polish\n**Goal:** refine.\n`, + 'utf-8', + ); + + const r = runGsdTools('init phase-op 3', localDir); + assert.ok(r.success, `init phase-op 3 failed: ${r.error}`); + + const out = JSON.parse(r.output); + assert.strictEqual(out.phase_found, true, `phase_found should be true; got phase_found=${out.phase_found}`); + assert.strictEqual(out.phase_name, 'Polish', `phase_name should be 'Polish'; got '${out.phase_name}'`); + assert.strictEqual(out.padded_phase, '03', `padded_phase should be '03'; got '${out.padded_phase}'`); + assert.strictEqual(out.expected_phase_dir, '.planning/phases/03-polish', `expected_phase_dir should be '.planning/phases/03-polish'; got '${out.expected_phase_dir}'`); + } finally { + cleanup(localDir); + } + }); + + // ------------------------------------------------------------------------- + // Test 6: sub-milestone sharing a version prefix — closed sibling must NOT + // cross-pollinate into the active milestone's Phase Details lookup (#730) + // ------------------------------------------------------------------------- + test('init phase-op anchors Phase Details to the selected sub-milestone, not a closed same-prefix sibling', () => { + const localDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-730-t6-')); + try { + const planning = path.join(localDir, '.planning'); + fs.mkdirSync(planning, { recursive: true }); + + // STATE.md — milestone: v3.0 (matches v3.0-B active slice) + fs.writeFileSync( + path.join(planning, 'STATE.md'), + `---\nmilestone: v3.0\n---\n`, + 'utf-8', + ); + + // ROADMAP.md — v3.0-A is SHIPPED (closed), v3.0-B is active. + // The Phase Details for v3.0-A comes FIRST — without version-boundary + // anchoring the old code would grab it (first non-closed (Phase Details) + // heading outside the window), returning phase_name='Alpha' instead of 'Beta'. + fs.writeFileSync( + path.join(planning, 'ROADMAP.md'), + [ + '# Roadmap: Example', + '', + '## Phases', + '', + '### Milestone v3.0-A — First slice (added 2026-01-01) ✅ SHIPPED', + '', + '- [x] **Phase 1: Alpha** — done', + '', + '### Milestone v3.0-B — Second slice (added 2026-02-01)', + '', + '- [ ] **Phase 2: Beta** — current', + '', + '## Phase Details', + '', + '## Milestone v3.0-A — First slice (Phase Details)', + '', + '### Phase 1: Alpha', + '**Goal:** alpha goal.', + '', + '## Milestone v3.0-B — Second slice (Phase Details)', + '', + '### Phase 2: Beta', + '**Goal:** beta goal.', + '', + ].join('\n'), + 'utf-8', + ); + + const r = runGsdTools('init phase-op 2', localDir); + assert.ok(r.success, `init phase-op 2 failed: ${r.error}`); + + const out = JSON.parse(r.output); + assert.strictEqual(out.phase_found, true, `phase_found should be true; got phase_found=${out.phase_found}, output=${JSON.stringify(out)}`); + assert.strictEqual(out.phase_name, 'Beta', `phase_name should be 'Beta' (v3.0-B section), not '${out.phase_name}' (would indicate v3.0-A cross-pollination)`); + assert.strictEqual(out.expected_phase_dir, '.planning/phases/02-beta', `expected_phase_dir should be '.planning/phases/02-beta'; got '${out.expected_phase_dir}'`); + } finally { + cleanup(localDir); + } + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3128-roadmap-plan-count-slug-layout.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3128-roadmap-plan-count-slug-layout (consolidation epic #1969 B3 #1972)", () => { +'use strict'; +// allow-test-rule: reads roadmap.cjs source to verify isPlanFile pattern was adopted — structural contract prevents silent regression to old filter (see #3128) + +// Regression guard for bug #3128. +// +// roadmap.cjs countPhasePlansAndSummaries() used to filter plan files with: +// f.endsWith('-PLAN.md') || f === 'PLAN.md' +// This misses the {N}-PLAN-{NN}-{slug}.md layout that gsd-plan-phase +// actually writes (e.g. 5-PLAN-01-setup-database.md), ending in -database.md. +// Result: init manager returned plan_count=0 and disk_status='discussed' for +// fully-planned phases, triggering unnecessary background planner agents. +// +// Root cause: same regex flaw as #2893 (fixed in phase.cjs via #2896), but +// the manager-dashboard path in roadmap.cjs was not updated alongside it. +// +// Fix: apply the same looksLikePlanFile logic from phase.cjs to roadmap.cjs. + +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, '..'); +// Require the module under test directly +const roadmapLib = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'roadmap.cjs'); +const planScanLib = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'plan-scan.cjs'); + +// We test countPhasePlansAndSummaries indirectly via getManagerInfo since +// it is not exported. We build a real phaseDir on disk and call the full +// roadmap.cjs init manager path via its exported helper, or fall back to +// direct filesystem inspection of what the filter would produce. +// The simplest correct seam: inspect the source for the regex pattern and +// validate with a synthetic directory that the manager path returns correct counts. + + +// Import countPhasePlansAndSummaries by monkey-patching: we inline the +// fixed filter logic and verify it matches the file on disk. +// Since the function is module-private, we validate via its public caller +// by using the exported analyzeRoadmap / getPhaseInfo path with a +// synthetic .planning/ directory tree. + +describe('bug #3128: roadmap.cjs plan-count for {N}-PLAN-{NN}-{slug}.md layout', () => { + + test('isPlanFile rejects PLAN-OUTLINE and pre-bounce derivatives', () => { + // Inlined from fix — mirrors the exact logic in the fix + const PLAN_OUTLINE_RE = /-PLAN-OUTLINE\.md$/i; + const PLAN_PRE_BOUNCE_RE = /-PLAN.*\.pre-bounce\.md$/i; + const isPlanFile = (f) => + (f.endsWith('-PLAN.md') || f === 'PLAN.md') || + (/\.md$/i.test(f) && /PLAN/i.test(f) && !PLAN_OUTLINE_RE.test(f) && !PLAN_PRE_BOUNCE_RE.test(f)); + + // canonical forms — must match + assert.ok(isPlanFile('PLAN.md'), 'PLAN.md must match'); + assert.ok(isPlanFile('5-PLAN.md'), '5-PLAN.md must match'); + assert.ok(isPlanFile('05-PLAN.md'), '05-PLAN.md must match'); + + // slug form — was the bug; must now match + assert.ok(isPlanFile('5-PLAN-01-setup.md'), '5-PLAN-01-setup.md must match'); + assert.ok(isPlanFile('05-PLAN-02-database.md'), '05-PLAN-02-database.md must match'); + assert.ok(isPlanFile('5-PLAN-DELTA-2026-05-05.md'), '5-PLAN-DELTA-2026-05-05.md must match'); + + // derivative files — must NOT match + assert.ok(!isPlanFile('5-PLAN-OUTLINE.md'), 'PLAN-OUTLINE must not match'); + assert.ok(!isPlanFile('5-PLAN-01.pre-bounce.md'), 'pre-bounce must not match'); + assert.ok(!isPlanFile('CONTEXT.md'), 'CONTEXT.md must not match'); + assert.ok(!isPlanFile('SUMMARY.md'), 'SUMMARY.md must not match'); + assert.ok(!isPlanFile('5-RESEARCH.md'), 'RESEARCH.md must not match'); + }); + + test('roadmap.cjs source uses the extended isPlanFile filter', () => { + const roadmapSrc = fs.readFileSync(roadmapLib, 'utf8'); + // Verify the fix is in place: the old simple inline filter is gone from roadmap.cjs + assert.ok( + !roadmapSrc.includes("phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md')"), + 'Old simple plan filter still present in roadmap.cjs — fix not applied', + ); + // roadmap.cjs now delegates to plan-scan.cjs via require('./plan-scan.cjs') + assert.ok( + roadmapSrc.includes('plan-scan.cjs'), + 'roadmap.cjs does not require plan-scan.cjs — delegation not applied', + ); + // plan-scan.cjs is where the extended plan-file detection logic lives (isRootPlanFile) + const planScanSrc = fs.readFileSync(planScanLib, 'utf8'); + assert.ok( + planScanSrc.includes('isRootPlanFile') && planScanSrc.includes('/PLAN/i'), + 'isRootPlanFile with /PLAN/i not found in plan-scan.cjs — canonical helper missing extended filter', + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-500-planned-phase-progress-corruption.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-500-planned-phase-progress-corruption (consolidation epic #1969 B3 #1972)", () => { +/** + * Bug #500: `state planned-phase` corrupts STATE.md milestone progress.* counters. + * + * Two independent defects: + * + * RC1 — plan-phase resyncs progress it should not touch. + * cmdStatePlannedPhase wrote via writeStateMd, which unconditionally runs + * syncStateFrontmatter and rebuilds progress.* from a half-planned disk + * snapshot, trampling curated counters. It must route through + * readModifyWriteStateMd(..., { resync: false }) like other body-only writes. + * + * RC2 — isRootPlanFile double-counts legacy `-PLAN--SUMMARY.md` as a plan. + * The final `/PLAN/i` fallback matches the substring "PLAN" inside a legacy + * summary name, so a 4-plan/4-summary phase scans as planCount:8 / completed:false + * instead of planCount:4 / completed:true. A summary is never a plan. + */ + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); +const planScan = require('../gsd-core/bin/lib/plan-scan.cjs'); +const { isRootPlanFile, scanPhasePlans } = planScan; + +describe('isRootPlanFile does not count legacy summaries as plans (#500 RC2)', () => { + test('legacy -PLAN--SUMMARY.md is not a root plan file', () => { + assert.equal(isRootPlanFile('14-PLAN-01-SUMMARY.md'), false); + }); + + test('legacy -PLAN-.md is still a root plan file', () => { + assert.equal(isRootPlanFile('14-PLAN-01.md'), true); + }); + + test('canonical -PLAN.md is still a root plan file', () => { + assert.equal(isRootPlanFile('01-PLAN.md'), true); + }); + + test('a 4-plan / 4-summary legacy phase scans as planCount:4 completed:true', () => { + const tmp = createTempProject(); + const phaseDir = path.join(tmp, '.planning', 'phases', '14-legacy'); + fs.mkdirSync(phaseDir, { recursive: true }); + for (let i = 1; i <= 4; i++) { + const nn = String(i).padStart(2, '0'); + fs.writeFileSync(path.join(phaseDir, `14-PLAN-${nn}.md`), '# Plan\n', 'utf-8'); + fs.writeFileSync(path.join(phaseDir, `14-PLAN-${nn}-SUMMARY.md`), '# Summary\n', 'utf-8'); + } + try { + const scan = scanPhasePlans(phaseDir); + assert.equal(scan.planCount, 4, `expected 4 plans, got ${scan.planCount}`); + assert.equal(scan.summaryCount, 4, `expected 4 summaries, got ${scan.summaryCount}`); + assert.equal(scan.completed, true, 'a fully-summarized phase must scan as completed'); + } finally { + cleanup(tmp); + } + }); +}); + +describe('state planned-phase preserves curated milestone progress.* (#500 RC1)', () => { + let tmpDir; + + // Curated progress counters that deliberately do NOT match what a disk scan + // would derive (disk has only one near-empty phase dir). The bug rebuilds + // progress.* from that disk snapshot, trampling these values. + const STATE = `--- +gsd_state_version: 1.0 +milestone: v3.0 +milestone_name: Active +status: in_progress +progress: + total_phases: 7 + completed_phases: 5 + total_plans: 99 + completed_plans: 88 + percent: 88 +--- + +# Project State + +## Configuration +Current Phase: 2 +Current Phase Name: builder +Total Plans in Phase: 0 +Status: Not started +Last Activity: TBD +Last Activity Description: pending + +## Current Position + +Phase: 2 (builder) +Status: Not started +Last activity: TBD +`; + + beforeEach(() => { + tmpDir = createTempProject(); + const planning = path.join(tmpDir, '.planning'); + fs.writeFileSync(path.join(planning, 'STATE.md'), STATE, 'utf-8'); + fs.writeFileSync( + path.join(planning, 'ROADMAP.md'), + '# Roadmap\n\n## 🚧 v3.0: Active\n\n### Phase 2: builder\n', + 'utf-8' + ); + fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8'); + // One sparse phase dir so a disk resync would derive small/zero counters. + const dir = path.join(planning, 'phases', '02-builder'); + fs.mkdirSync(dir, { recursive: true }); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + function readProgress() { + const md = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); + const block = md.split('---')[1] || ''; + const num = (key) => { + const m = block.match(new RegExp(`${key}:\\s*(\\d+)`)); + return m ? Number(m[1]) : null; + }; + return { + total_plans: num('total_plans'), + completed_plans: num('completed_plans'), + total_phases: num('total_phases'), + completed_phases: num('completed_phases'), + }; + } + + test('planned-phase updates per-phase body fields but leaves milestone progress.* untouched', () => { + const result = runGsdTools(['state', 'planned-phase', '--phase', '2', '--plans', '3'], tmpDir); + assert.equal(result.success, true, result.error || result.output); + + // The command did its real job: per-phase "Total Plans in Phase" was set. + const md = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); + assert.match(md, /Total Plans in Phase:\s*3/, 'per-phase Total Plans in Phase should be updated to 3'); + + // ...but the curated milestone-wide progress block is preserved verbatim. + assert.deepEqual(readProgress(), { + total_plans: 99, + completed_plans: 88, + total_phases: 7, + completed_phases: 5, + }, 'curated milestone progress.* must survive a planned-phase write'); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/feat-3262-scan-phase-plans.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:feat-3262-scan-phase-plans (consolidation epic #1969 B3 #1972)", () => { +/** + * Tests for the shared scanPhasePlans() helper (k014). + * + * Covers: + * - Top-level plans only (flat layout) + * - Top-level + nested layout (post-#3139) + * - Completed-summary detection (summaries >= plans) + * - Ignored files (OUTLINE, pre-bounce, CONTEXT, RESEARCH) + * - Empty phase dir → { planCount: 0, summaryCount: 0 } + * - Parity: helper produces correct counts for mixed flat+nested fixture tree + */ + +'use strict'; + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { cleanup } = require('./helpers.cjs'); + +// Helper under test — must exist at this path (GREEN phase wires it up) +const scanPhasePlans = require('../gsd-core/bin/lib/plan-scan.cjs'); + +// --------------------------------------------------------------------------- +// Fixture helpers +// --------------------------------------------------------------------------- + +let tmpDir; + +function phaseDir(name = 'phase') { + const d = path.join(tmpDir, name); + fs.mkdirSync(d, { recursive: true }); + return d; +} + +function touch(dir, ...filenames) { + for (const f of filenames) { + fs.writeFileSync(path.join(dir, f), ''); + } +} + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-plan-scan-')); +}); + +afterEach(() => { + cleanup(tmpDir); +}); + +// --------------------------------------------------------------------------- +// Basic shapes +// --------------------------------------------------------------------------- + +describe('scanPhasePlans — flat layout', () => { + test('empty directory → zero counts', () => { + const dir = phaseDir(); + const result = scanPhasePlans(dir); + assert.strictEqual(result.planCount, 0, 'planCount'); + assert.strictEqual(result.summaryCount, 0, 'summaryCount'); + }); + + test('bare PLAN.md counts as one plan', () => { + const dir = phaseDir(); + touch(dir, 'PLAN.md'); + const result = scanPhasePlans(dir); + assert.strictEqual(result.planCount, 1, 'planCount'); + assert.strictEqual(result.summaryCount, 0, 'summaryCount'); + }); + + test('canonical padded plan file (01-01-PLAN.md)', () => { + const dir = phaseDir(); + touch(dir, '01-01-PLAN.md'); + const result = scanPhasePlans(dir); + assert.strictEqual(result.planCount, 1, 'planCount'); + }); + + test('canonical padded plan + matching summary → completed', () => { + const dir = phaseDir(); + touch(dir, '01-01-PLAN.md', '01-01-SUMMARY.md'); + const result = scanPhasePlans(dir); + assert.strictEqual(result.planCount, 1); + assert.strictEqual(result.summaryCount, 1); + assert.strictEqual(result.completed, true, 'phase should be complete when summaries >= plans'); + }); + + test('plan without summary → not completed', () => { + const dir = phaseDir(); + touch(dir, '01-01-PLAN.md'); + const result = scanPhasePlans(dir); + assert.strictEqual(result.completed, false); + }); + + test('multiple plans all summarized → completed', () => { + const dir = phaseDir(); + touch(dir, '01-01-PLAN.md', '01-02-PLAN.md', '01-01-SUMMARY.md', '01-02-SUMMARY.md'); + const result = scanPhasePlans(dir); + assert.strictEqual(result.planCount, 2); + assert.strictEqual(result.summaryCount, 2); + assert.strictEqual(result.completed, true); + }); + + test('bare SUMMARY.md counts as one summary', () => { + const dir = phaseDir(); + touch(dir, 'PLAN.md', 'SUMMARY.md'); + const result = scanPhasePlans(dir); + assert.strictEqual(result.planCount, 1); + assert.strictEqual(result.summaryCount, 1); + }); + + test('extended-layout root file (5-PLAN-01-setup.md style)', () => { + // roadmap.cjs isPlanFile explicitly matches any .md with PLAN in name at root + // (not just ending with -PLAN.md). The canonical helper must too. + // e.g. gsd-plan-phase writes "5-PLAN-01-setup.md". + const dir = phaseDir(); + // The summary for this file follows the canonical *-SUMMARY.md suffix convention. + touch(dir, '3-PLAN-01-setup.md', '3-01-SUMMARY.md'); + const result = scanPhasePlans(dir); + assert.strictEqual(result.planCount, 1, 'extended-layout root plan counted'); + assert.strictEqual(result.summaryCount, 1, 'extended-layout root summary counted'); + }); +}); + +// --------------------------------------------------------------------------- +// Ignored files +// --------------------------------------------------------------------------- + +describe('scanPhasePlans — ignored files', () => { + test('PLAN-OUTLINE file is ignored (flat)', () => { + const dir = phaseDir(); + touch(dir, '01-01-PLAN.md', '01-01-PLAN-OUTLINE.md'); + const result = scanPhasePlans(dir); + assert.strictEqual(result.planCount, 1, 'OUTLINE should not count as a plan'); + }); + + test('pre-bounce file is ignored (flat)', () => { + const dir = phaseDir(); + touch(dir, '01-01-PLAN.md', '01-01-PLAN.pre-bounce.md'); + const result = scanPhasePlans(dir); + assert.strictEqual(result.planCount, 1, 'pre-bounce should not count as a plan'); + }); + + test('CONTEXT.md is not counted as a plan', () => { + const dir = phaseDir(); + touch(dir, 'PLAN.md', 'CONTEXT.md', '01-01-CONTEXT.md'); + const result = scanPhasePlans(dir); + assert.strictEqual(result.planCount, 1, 'CONTEXT files should not be plans'); + }); + + test('RESEARCH.md is not counted as a plan', () => { + const dir = phaseDir(); + touch(dir, 'PLAN.md', 'RESEARCH.md', '01-01-RESEARCH.md'); + const result = scanPhasePlans(dir); + assert.strictEqual(result.planCount, 1, 'RESEARCH files should not be plans'); + }); + + test('VERIFICATION.md is not counted as a plan', () => { + const dir = phaseDir(); + touch(dir, 'PLAN.md', 'VERIFICATION.md'); + const result = scanPhasePlans(dir); + assert.strictEqual(result.planCount, 1, 'VERIFICATION files should not be plans'); + }); +}); + +// --------------------------------------------------------------------------- +// Nested layout (post-#3139) +// --------------------------------------------------------------------------- + +describe('scanPhasePlans — nested layout', () => { + test('nested PLAN-NN-slug.md files counted', () => { + const dir = phaseDir(); + const plansDir = path.join(dir, 'plans'); + fs.mkdirSync(plansDir); + touch(plansDir, 'PLAN-01-setup.md', 'PLAN-02-impl.md'); + const result = scanPhasePlans(dir); + assert.strictEqual(result.planCount, 2, 'nested plans counted'); + assert.strictEqual(result.hasNestedPlans, true, 'hasNestedPlans flag set'); + }); + + test('nested SUMMARY-NN-slug.md files counted', () => { + const dir = phaseDir(); + const plansDir = path.join(dir, 'plans'); + fs.mkdirSync(plansDir); + touch(plansDir, 'PLAN-01-setup.md', 'SUMMARY-01-setup.md'); + const result = scanPhasePlans(dir); + assert.strictEqual(result.planCount, 1); + assert.strictEqual(result.summaryCount, 1); + assert.strictEqual(result.completed, true); + assert.deepStrictEqual(result.planFiles, ['plans/PLAN-01-setup.md']); + assert.deepStrictEqual(result.summaryFiles, ['plans/SUMMARY-01-setup.md']); + }); + + test('flat root + nested plans combined', () => { + const dir = phaseDir(); + const plansDir = path.join(dir, 'plans'); + fs.mkdirSync(plansDir); + // root: 1 plan, 1 summary + touch(dir, '01-01-PLAN.md', '01-01-SUMMARY.md'); + // nested: 2 plans, 1 summary + touch(plansDir, 'PLAN-01-setup.md', 'PLAN-02-impl.md', 'SUMMARY-01-setup.md'); + const result = scanPhasePlans(dir); + assert.strictEqual(result.planCount, 3, 'root + nested plans'); + assert.strictEqual(result.summaryCount, 2, 'root + nested summaries'); + assert.strictEqual(result.completed, false, 'not all plans have summaries'); + }); + + test('hasNestedPlans is false when plans/ directory absent', () => { + const dir = phaseDir(); + touch(dir, 'PLAN.md'); + const result = scanPhasePlans(dir); + assert.strictEqual(result.hasNestedPlans, false); + }); + + test('nested OUTLINE files are ignored', () => { + const dir = phaseDir(); + const plansDir = path.join(dir, 'plans'); + fs.mkdirSync(plansDir); + touch(plansDir, 'PLAN-01-setup.md', 'PLAN-01-OUTLINE.md'); + const result = scanPhasePlans(dir); + assert.strictEqual(result.planCount, 1, 'OUTLINE excluded in nested'); + }); + + test('nested pre-bounce files are ignored', () => { + const dir = phaseDir(); + const plansDir = path.join(dir, 'plans'); + fs.mkdirSync(plansDir); + touch(plansDir, 'PLAN-01-setup.md', 'PLAN-01.pre-bounce.md'); + const result = scanPhasePlans(dir); + assert.strictEqual(result.planCount, 1, 'pre-bounce excluded in nested'); + }); + + test('plans/ that is not readable as directory does not throw', () => { + const dir = phaseDir(); + // Create plans/ as a file (unreadable as directory) + fs.writeFileSync(path.join(dir, 'plans'), 'not-a-directory'); + touch(dir, 'PLAN.md'); + // Should not throw + const result = scanPhasePlans(dir); + assert.strictEqual(result.planCount, 1); + assert.strictEqual(result.hasNestedPlans, false); + }); +}); + +// --------------------------------------------------------------------------- +// Parity: helper output shape and mixed fixture +// --------------------------------------------------------------------------- + +describe('scanPhasePlans — call-site parity on mixed fixture', () => { + // Build a fixture tree that exercises both flat and nested layout: + // 01-foundation/ + // 01-01-PLAN.md + // 01-01-SUMMARY.md + // 01-01-PLAN-OUTLINE.md (should be ignored) + // 01-02-PLAN.md + // plans/ + // PLAN-01-setup.md + // SUMMARY-01-setup.md + + function buildMixedPhase() { + const dir = phaseDir('01-foundation'); + const plansDir = path.join(dir, 'plans'); + fs.mkdirSync(plansDir); + touch(dir, '01-01-PLAN.md', '01-01-SUMMARY.md', '01-01-PLAN-OUTLINE.md', '01-02-PLAN.md'); + touch(plansDir, 'PLAN-01-setup.md', 'SUMMARY-01-setup.md'); + return dir; + } + + test('scanPhasePlans() counts match expected values for mixed fixture', () => { + const dir = buildMixedPhase(); + const result = scanPhasePlans(dir); + // flat: 01-01-PLAN.md + 01-02-PLAN.md = 2 (OUTLINE ignored) + // nested: PLAN-01-setup.md = 1 + assert.strictEqual(result.planCount, 3, 'planCount should be 3'); + // flat: 01-01-SUMMARY.md = 1; nested: SUMMARY-01-setup.md = 1 + assert.strictEqual(result.summaryCount, 2, 'summaryCount should be 2'); + assert.strictEqual(result.completed, false, 'not all plans have summaries'); + assert.strictEqual(result.hasNestedPlans, true, 'nested layout present'); + }); + + test('scanPhasePlans() output shape has required fields', () => { + const dir = buildMixedPhase(); + const result = scanPhasePlans(dir); + assert.ok('planCount' in result, 'planCount field present'); + assert.ok('summaryCount' in result, 'summaryCount field present'); + assert.ok('completed' in result, 'completed field present'); + assert.ok('hasNestedPlans' in result, 'hasNestedPlans field present'); + assert.ok('planFiles' in result, 'planFiles field present'); + assert.ok('summaryFiles' in result, 'summaryFiles field present'); + assert.ok(Array.isArray(result.planFiles), 'planFiles is array'); + assert.ok(Array.isArray(result.summaryFiles), 'summaryFiles is array'); + }); + + test('parity baseline: 2 flat + 1 nested plans across all call sites', () => { + // This test documents the exact expected counts for a representative fixture. + // After the GREEN phase ports roadmap.cjs/state.cjs/init.cjs to use + // scanPhasePlans, those call sites delegate here and this assertion is + // the single contract all of them must satisfy. + const dir = phaseDir('02-api'); + touch(dir, '02-01-PLAN.md', '02-02-PLAN.md', '02-01-SUMMARY.md'); + const plansDir = path.join(dir, 'plans'); + fs.mkdirSync(plansDir); + touch(plansDir, 'PLAN-01-impl.md', 'SUMMARY-01-impl.md'); + + const result = scanPhasePlans(dir); + assert.strictEqual(result.planCount, 3, 'helper: 2 flat + 1 nested'); + assert.strictEqual(result.summaryCount, 2, 'helper: 1 flat + 1 nested'); + assert.strictEqual(result.completed, false, '2 summaries < 3 plans'); + assert.strictEqual(result.hasNestedPlans, true, 'plans/ dir exists with plans'); + }); +}); + }); +} diff --git a/tests/roadmapper-granularity.test.cjs b/tests/roadmapper-granularity.test.cjs index c7a067522..61bdd9b2f 100644 --- a/tests/roadmapper-granularity.test.cjs +++ b/tests/roadmapper-granularity.test.cjs @@ -130,3 +130,523 @@ describe('gsd-roadmapper phase_id_convention support (#1205)', () => { ); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/feat-68-per-phase-granularity.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:feat-68-per-phase-granularity (consolidation epic #1969 B3 #1972)", () => { +/** + * Feature test for issue #68 — per-phase granularity. + * + * Adds a `granularities` block to .planning/config.json that accepts phase-type + * keys (planning / discuss / research / execution / verification / + * completion). Resolution precedence: + * + * 1. granularities[phaseType] — per-phase override (enum-guarded) + * 2. top-level `granularity` — global override (new-project / legacy depth) + * 3. planning.granularity — canonical global default (always present post-merge) + * 4. 'standard' — hard fallback + * + * Tests are typed-IR / structural — assert on the value returned by + * resolveGranularityInternal, not stdout/grep. Each test seeds a temp project + * with a fixture .planning/config.json and asserts the resolver picks + * the right granularity for each phase type. + * + * Structure mirrors tests/feat-3023-model-phase-types.test.cjs exactly. + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { + resolveGranularityInternal, + VALID_GRANULARITIES, +} = require('../gsd-core/bin/lib/model-resolver.cjs'); +const commands = require('../gsd-core/bin/lib/commands.cjs'); +const { + VALID_PHASE_TYPES, +} = require('../gsd-core/bin/lib/model-profiles.cjs'); +const { isValidConfigKey } = require('../gsd-core/bin/lib/config-schema.cjs'); + +const { createTempDir, runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); +const makeTmp = (prefix) => createTempDir(`gsd-68-${prefix}-`); + +function writeConfig(projectDir, config) { + const planningDir = path.join(projectDir, '.planning'); + fs.mkdirSync(planningDir, { recursive: true }); + fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify(config, null, 2)); +} + +// ─── Export check ──────────────────────────────────────────────────────────── + +describe('#68 exports: resolveGranularityInternal and VALID_GRANULARITIES are exported', () => { + test('resolveGranularityInternal is a function', () => { + assert.equal(typeof resolveGranularityInternal, 'function'); + }); + + test('VALID_GRANULARITIES is a Set containing coarse, standard, fine', () => { + assert.ok(VALID_GRANULARITIES instanceof Set); + assert.deepStrictEqual( + [...VALID_GRANULARITIES].sort(), + ['coarse', 'fine', 'standard'].sort() + ); + }); +}); + +// ─── Schema: granularities. validation ────────────────────────── + +describe('#68 config-schema: granularities. validation', () => { + test('granularities.planning is a valid config key', () => { + assert.equal(isValidConfigKey('granularities.planning'), true); + }); + + test('all six phase-type slots are valid config keys', () => { + for (const slot of ['planning', 'discuss', 'research', 'execution', 'verification', 'completion']) { + assert.equal(isValidConfigKey(`granularities.${slot}`), true, + `granularities.${slot} must be a valid config key`); + } + }); + + test('unknown phase-type is rejected', () => { + assert.equal(isValidConfigKey('granularities.bogus'), false, + 'unknown phase-type must NOT be accepted'); + assert.equal(isValidConfigKey('granularities.deployment'), false, + 'unknown phase-type must NOT be accepted'); + }); + + test('granularities alone (without a slot) is not a valid config-set key — mirrors models behavior', () => { + // Setting the whole block isn't a granular set; users edit JSON directly. + assert.equal(isValidConfigKey('granularities'), false); + }); +}); + +// ─── Resolver behavior: per-phase override wins ────────────────────────────── + +describe('#68 resolver: granularities. overrides global granularity', () => { + let projectDir; + beforeEach(() => { projectDir = makeTmp('resolver'); }); + afterEach(() => { cleanup(projectDir); }); + + test('per-phase override wins: granularities.planning=fine resolves to fine', () => { + writeConfig(projectDir, { + granularity: 'standard', + granularities: { planning: 'fine' }, + }); + assert.equal(resolveGranularityInternal(projectDir, 'planning'), 'fine'); + }); + + test('phase type with no per-phase override falls back to global granularity', () => { + writeConfig(projectDir, { + granularity: 'coarse', + granularities: { planning: 'fine' }, + }); + // 'execution' has no per-phase override → falls back to top-level granularity + assert.equal(resolveGranularityInternal(projectDir, 'execution'), 'coarse'); + }); + + test('all six phase types can be overridden independently', () => { + writeConfig(projectDir, { + granularity: 'standard', + granularities: { + planning: 'fine', + discuss: 'coarse', + research: 'fine', + execution: 'coarse', + verification: 'fine', + completion: 'coarse', + }, + }); + assert.equal(resolveGranularityInternal(projectDir, 'planning'), 'fine'); + assert.equal(resolveGranularityInternal(projectDir, 'discuss'), 'coarse'); + assert.equal(resolveGranularityInternal(projectDir, 'research'), 'fine'); + assert.equal(resolveGranularityInternal(projectDir, 'execution'), 'coarse'); + assert.equal(resolveGranularityInternal(projectDir, 'verification'), 'fine'); + assert.equal(resolveGranularityInternal(projectDir, 'completion'), 'coarse'); + }); +}); + +// ─── Resolver: invalid per-phase value falls through ───────────────────────── + +describe('#68 resolver: invalid per-phase value falls through to global (typo safety)', () => { + let projectDir; + beforeEach(() => { projectDir = makeTmp('invalid'); }); + afterEach(() => { cleanup(projectDir); }); + + test('invalid value ultra falls through to global granularity', () => { + writeConfig(projectDir, { + granularity: 'coarse', + granularities: { planning: 'ultra' }, // not a valid enum value + }); + // Falls through to top-level granularity + assert.equal(resolveGranularityInternal(projectDir, 'planning'), 'coarse'); + }); + + test('invalid value empty-string falls through to global granularity', () => { + writeConfig(projectDir, { + granularity: 'fine', + granularities: { planning: '' }, + }); + assert.equal(resolveGranularityInternal(projectDir, 'planning'), 'fine'); + }); +}); + +// ─── Resolver: malformed granularities block doesn't throw ─────────────────── + +describe('#68 resolver: malformed granularities value does not throw', () => { + let projectDir; + beforeEach(() => { projectDir = makeTmp('malformed'); }); + afterEach(() => { cleanup(projectDir); }); + + test('granularities as a string does not throw, returns global fallback', () => { + writeConfig(projectDir, { + granularity: 'coarse', + granularities: 'fine', // string, not an object + }); + assert.doesNotThrow(() => resolveGranularityInternal(projectDir, 'planning')); + assert.equal(resolveGranularityInternal(projectDir, 'planning'), 'coarse'); + }); + + test('granularities as null does not throw, returns global fallback', () => { + writeConfig(projectDir, { + granularity: 'coarse', + granularities: null, + }); + assert.doesNotThrow(() => resolveGranularityInternal(projectDir, 'planning')); + assert.equal(resolveGranularityInternal(projectDir, 'planning'), 'coarse'); + }); + + test('granularities as an array does not throw, returns global fallback', () => { + writeConfig(projectDir, { + granularity: 'fine', + granularities: ['fine'], + }); + assert.doesNotThrow(() => resolveGranularityInternal(projectDir, 'planning')); + assert.equal(resolveGranularityInternal(projectDir, 'planning'), 'fine'); + }); +}); + +// ─── Backward-compat (Hyrum): no granularities key mirrors pre-feature behavior + +describe('#68 backward-compat: no granularities key resolves identically to pre-feature global', () => { + let projectDir; + beforeEach(() => { projectDir = makeTmp('compat'); }); + afterEach(() => { cleanup(projectDir); }); + + test('top-level granularity=fine resolves to fine for all six phase types (no granularities key)', () => { + writeConfig(projectDir, { + granularity: 'fine', + }); + for (const phaseType of ['planning', 'discuss', 'research', 'execution', 'verification', 'completion']) { + assert.equal(resolveGranularityInternal(projectDir, phaseType), 'fine', + `${phaseType} must resolve to fine`); + } + }); + + test('no granularity key at all → all phase types resolve to standard (canonical default)', () => { + writeConfig(projectDir, {}); + for (const phaseType of ['planning', 'discuss', 'research', 'execution', 'verification', 'completion']) { + assert.equal(resolveGranularityInternal(projectDir, phaseType), 'standard', + `${phaseType} must resolve to standard (canonical default)`); + } + }); +}); + +// ─── Global precedence chain ───────────────────────────────────────────────── + +describe('#68 resolver: global fallback precedence chain', () => { + let projectDir; + beforeEach(() => { projectDir = makeTmp('precedence'); }); + afterEach(() => { cleanup(projectDir); }); + + test('top-level granularity honored when present', () => { + writeConfig(projectDir, { + granularity: 'coarse', + planning: { granularity: 'fine' }, // planning.granularity is lower precedence + }); + assert.equal(resolveGranularityInternal(projectDir, 'execution'), 'coarse'); + }); + + test('planning.granularity honored when top-level granularity absent', () => { + writeConfig(projectDir, { + planning: { granularity: 'fine' }, + }); + assert.equal(resolveGranularityInternal(projectDir, 'execution'), 'fine'); + }); + + test('hard default standard when neither top-level nor planning.granularity present', () => { + writeConfig(projectDir, {}); + assert.equal(resolveGranularityInternal(projectDir, 'execution'), 'standard'); + }); + + test('per-phase override beats all global sources', () => { + writeConfig(projectDir, { + granularity: 'coarse', + planning: { granularity: 'coarse' }, + granularities: { planning: 'fine' }, + }); + assert.equal(resolveGranularityInternal(projectDir, 'planning'), 'fine'); + }); +}); + +// ─── VALID_PHASE_TYPES consistency ────────────────────────────────────────── + +describe('#68 VALID_PHASE_TYPES covers all six slots used by granularities', () => { + test('the six granularities slots are all valid phase types', () => { + for (const slot of ['planning', 'discuss', 'research', 'execution', 'verification', 'completion']) { + assert.ok(VALID_PHASE_TYPES.has(slot), + `${slot} must be in VALID_PHASE_TYPES`); + } + }); +}); + +// ─── CMD-level: cmdResolveGranularity export + CLI behavior ───────────────── +// Mirrors the resolve-model command tests in tests/commands.test.cjs (CMD-03). + +describe('#68 exports: cmdResolveGranularity is exported as a function', () => { + test('cmdResolveGranularity is a function', () => { + assert.equal(typeof commands.cmdResolveGranularity, 'function'); + }); +}); + +describe('#68 resolve-granularity command: CLI behavior', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('(a) missing phase-type arg → command exits with error mentioning phase-type required', () => { + const result = runGsdTools('resolve-granularity', tmpDir); + assert.ok(!result.success, 'should fail without phase-type'); + assert.ok(result.error.includes('phase-type required'), `error should mention phase-type required; got: ${result.error}`); + }); + + test('(b) unknown phase type → result includes unknown_phase_type: true', () => { + const result = runGsdTools('resolve-granularity nonexistent-phase', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.unknown_phase_type, true, 'should flag unknown phase type'); + assert.ok(output.granularity, 'should still return a granularity'); + }); + + test('(c) valid phase type with granularities override → returns override granularity', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ + granularity: 'standard', + granularities: { planning: 'fine' }, + }) + ); + const result = runGsdTools('resolve-granularity planning', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.granularity, 'fine', 'granularities.planning override should win'); + assert.strictEqual(output.phase_type, 'planning'); + assert.strictEqual(output.unknown_phase_type, undefined, 'known phase type must not have unknown_phase_type'); + }); +}); + +// ─── #703 CLI override: --granularity flag ──────────────────────────────────── + +describe('#703 resolveGranularityInternal: CLI override param (3rd arg)', () => { + let projectDir; + beforeEach(() => { projectDir = makeTmp('cli-override'); }); + afterEach(() => { cleanup(projectDir); }); + + test('override fine beats per-phase config granularities.planning=coarse', () => { + writeConfig(projectDir, { + granularity: 'standard', + granularities: { planning: 'coarse' }, + }); + assert.equal(resolveGranularityInternal(projectDir, 'planning', 'fine'), 'fine', + 'CLI override must beat per-phase config'); + }); + + test('override coarse beats top-level granularity=fine', () => { + writeConfig(projectDir, { + granularity: 'fine', + }); + assert.equal(resolveGranularityInternal(projectDir, 'execution', 'coarse'), 'coarse', + 'CLI override must beat top-level granularity'); + }); + + test('override standard beats planning.granularity=fine global fallback (regardless of phase type)', () => { + writeConfig(projectDir, { + planning: { granularity: 'fine' }, + }); + assert.equal(resolveGranularityInternal(projectDir, 'execution', 'standard'), 'standard', + 'CLI override must beat planning.granularity fallback'); + }); + + test("override '' (empty string) falls through to config chain", () => { + writeConfig(projectDir, { + granularity: 'coarse', + }); + assert.equal(resolveGranularityInternal(projectDir, 'planning', ''), 'coarse', + 'empty-string override must fall through to config chain'); + }); + + test('override undefined falls through to config chain', () => { + writeConfig(projectDir, { + granularity: 'fine', + }); + assert.equal(resolveGranularityInternal(projectDir, 'planning', undefined), 'fine', + 'undefined override must fall through to config chain'); + }); + + test('override null falls through to config chain', () => { + writeConfig(projectDir, { + granularity: 'coarse', + }); + assert.equal(resolveGranularityInternal(projectDir, 'planning', null), 'coarse', + 'null override must fall through to config chain'); + }); + + test('invalid override value falls through to config chain (not rejected in resolver)', () => { + writeConfig(projectDir, { + granularity: 'standard', + }); + // Invalid override reaches resolver → falls through (validation is CLI boundary's job) + assert.equal(resolveGranularityInternal(projectDir, 'planning', 'ultra'), 'standard', + 'invalid override must fall through to config chain in resolver'); + }); +}); + +describe('#703 cmdResolveGranularity: --granularity CLI flag', () => { + let tmpDir; + beforeEach(() => { tmpDir = createTempProject(); }); + afterEach(() => { cleanup(tmpDir); }); + + test('--granularity fine overrides config chain via CLI tool', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ granularity: 'coarse' }) + ); + const result = runGsdTools('resolve-granularity planning --granularity fine', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.granularity, 'fine', '--granularity fine must override config coarse'); + }); + + test('--granularity coarse overrides per-phase granularities.planning=fine', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ + granularity: 'standard', + granularities: { planning: 'fine' }, + }) + ); + const result = runGsdTools('resolve-granularity planning --granularity coarse', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.granularity, 'coarse', '--granularity coarse must beat per-phase fine'); + }); + + test('invalid --granularity value exits with error', () => { + const result = runGsdTools('resolve-granularity planning --granularity ultra', tmpDir); + assert.ok(!result.success, 'should fail with invalid granularity'); + assert.ok( + result.error.includes('ultra') || result.error.includes('invalid'), + `error should mention invalid value; got: ${result.error}` + ); + }); +}); + +// ─── #703 end-to-end: init.plan-phase path forwards and resolves granularity ── +// +// Fixture mirrors tests/pattern-mapper.test.cjs: createTempProject() + +// minimal STATE.md + ROADMAP.md + phase directory — just enough for +// cmdInitPlanPhase to succeed without agents / git. + +function makeInitPlanPhaseFixture(prefix) { + const tmpDir = createTempProject(prefix); + const planningDir = path.join(tmpDir, '.planning'); + fs.writeFileSync(path.join(planningDir, 'STATE.md'), [ + '# State', + '', + '## Current Phase', + 'Phase 1 — Foundation', + ].join('\n')); + fs.writeFileSync(path.join(planningDir, 'ROADMAP.md'), [ + '# Roadmap', + '', + '## Phase 1: Foundation', + 'Build the foundation.', + '**Status:** Planning', + '**Requirements:** [FOUND-01]', + ].join('\n')); + fs.mkdirSync(path.join(planningDir, 'phases', '01-foundation'), { recursive: true }); + return tmpDir; +} + +describe('#703 init.plan-phase end-to-end: granularity resolution via CLI', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = makeInitPlanPhaseFixture('gsd-68-e2e-'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('(1) --granularity fine overrides config granularities.planning=coarse end-to-end', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ granularity: 'standard', granularities: { planning: 'coarse' } }) + ); + const result = runGsdTools('init plan-phase 1 --granularity fine', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `init plan-phase failed: ${result.error}`); + const data = JSON.parse(result.output); + assert.strictEqual(data.granularity, 'fine', + '--granularity fine override must win over granularities.planning=coarse end-to-end'); + }); + + test('(2) no flag + config granularities.planning=fine → granularity=fine (Fix A: per-phase-type honored)', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ granularity: 'coarse', granularities: { planning: 'fine' } }) + ); + const result = runGsdTools('init plan-phase 1', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `init plan-phase failed: ${result.error}`); + const data = JSON.parse(result.output); + assert.strictEqual(data.granularity, 'fine', + 'granularities.planning=fine must be honored (phaseType=planning in resolveGranularityInternal)'); + }); + + test('(3) no flag + global granularity=coarse (no granularities.planning) → granularity=coarse', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ granularity: 'coarse' }) + ); + const result = runGsdTools('init plan-phase 1', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `init plan-phase failed: ${result.error}`); + const data = JSON.parse(result.output); + assert.strictEqual(data.granularity, 'coarse', + 'global granularity=coarse must be returned when no granularities.planning is set'); + }); + + test('(4) --granularity ultra → command errors (invalid value rejected on plan-phase path)', () => { + const result = runGsdTools('init plan-phase 1 --granularity ultra', tmpDir, { HOME: tmpDir }); + assert.ok(!result.success, 'should fail with invalid granularity ultra'); + assert.ok( + result.error.includes('ultra') || result.error.includes('invalid'), + `error should mention invalid value; got: ${result.error}` + ); + }); +}); + }); +} diff --git a/tests/runtime-artifact-layout-install-profiles.test.cjs b/tests/runtime-artifact-layout-install-profiles.test.cjs index 4a055b62c..ed94af736 100644 --- a/tests/runtime-artifact-layout-install-profiles.test.cjs +++ b/tests/runtime-artifact-layout-install-profiles.test.cjs @@ -659,3 +659,469 @@ describe('readActiveProfile / writeActiveProfile', () => { } }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3659-applysurface-prune-skill-dirs.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3659-applysurface-prune-skill-dirs (consolidation epic #1969 B3 #1972)", () => { +'use strict'; +/** + * Regression test for bug #3659 + * + * applySurface did not prune ~/.claude/skills/gsd-STEM dirs when a cluster + * was disabled. install/uninstall both prune correctly via _removeGsdEntries; + * applySurface called _syncGsdDir with the right logic but the surface.md spec + * directed the AI to use RUNTIME_CONFIG_DIR=~/.claude/skills (the skills dir + * itself) instead of the base Claude config dir (~/.claude). + * + * When runtimeConfigDir = ~/.claude/skills and scope = 'global': + * kind.destSubpath = 'skills' + * dest = path.join('~/.claude/skills', 'skills') = ~/.claude/skills/skills WRONG + * + * The pruning ran against the wrong (non-existent) dir so stale gsd-Y dirs + * were never removed from ~/.claude/skills/. + * + * Fix: + * 1. surface.md RUNTIME_CONFIG_DIR changed to use the base Claude config dir + * (getGlobalDir('claude') = ~/.claude), not ~/.claude/skills. + * 2. Surface state file moves to /.gsd-surface.json at the config + * root, matching install/uninstall conventions. + * 3. applySurface is called with scope='global' so the skills kind is active. + * + * Tests: + * a) disabled cluster gsd-STEM dirs are REMOVED from ~/.claude/skills/ + * b) gsd-STEM dirs in the retain set are preserved + * c) non-gsd dirs are UNTOUCHED (user-owned) + * d) idempotence: running applySurface twice produces the same on-disk state + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { writeSurface, applySurface } = require('../gsd-core/bin/lib/surface.cjs'); +const { loadSkillsManifest } = require('../gsd-core/bin/lib/install-profiles.cjs'); +const { CLUSTERS } = require('../gsd-core/bin/lib/clusters.cjs'); +const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); +const { createTempDir, cleanup } = require('./helpers.cjs'); + +const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); + +/** + * Build a minimal fixture simulating a Claude global install. + * + * configDir — analogous to ~/.claude + * skillsDir — analogous to ~/.claude/skills (contains gsd-* dirs) + * + * Pre-populated with: + * gsd-explore/SKILL.md — in research_ideate cluster (will be disabled) + * gsd-help/SKILL.md — in core_loop cluster (will remain enabled) + * my-custom-skill/ — user-owned, not gsd-prefixed (must never be touched) + */ +function createFixture() { + const configDir = createTempDir('gsd-bug3659-'); + const skillsDir = path.join(configDir, 'skills'); + fs.mkdirSync(skillsDir, { recursive: true }); + + const gsdExplore = path.join(skillsDir, 'gsd-explore'); + const gsdHelp = path.join(skillsDir, 'gsd-help'); + const userSkill = path.join(skillsDir, 'my-custom-skill'); + + for (const d of [gsdExplore, gsdHelp, userSkill]) { + fs.mkdirSync(d, { recursive: true }); + fs.writeFileSync(path.join(d, 'SKILL.md'), '# skill\n', 'utf8'); + } + + return { configDir, skillsDir, gsdExplore, gsdHelp, userSkill }; +} + +/** + * Extended fixture that also includes a user-created gsd-* directory. + * Used by the all-clusters-disabled counter-test to prove the manifest-membership + * gate (Finding 1 fix) protects user-owned gsd-* dirs from data loss. + */ +function createFixtureWithUserGsdDir() { + const base = createFixture(); + const userGsdDir = path.join(base.skillsDir, 'gsd-mything'); + fs.mkdirSync(userGsdDir, { recursive: true }); + fs.writeFileSync(path.join(userGsdDir, 'SKILL.md'), '# user skill\n', 'utf8'); + return { ...base, userGsdDir }; +} + +describe('bug-3659: applySurface prunes ~/.claude/skills/gsd-*/ on cluster disable', () => { + test('(a) disabled cluster gsd-* dirs are removed from skills dir', (t) => { + const { configDir, gsdExplore, gsdHelp } = createFixture(); + t.after(() => cleanup(configDir)); + + // Surface state at configDir (= ~/.claude), NOT at skillsDir (= ~/.claude/skills). + // This is the corrected location after the fix. + writeSurface(configDir, { + baseProfile: 'full', + disabledClusters: ['research_ideate'], // contains 'explore' + explicitAdds: [], + explicitRemoves: [], + }); + + const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); + // scope='global' gives the skills kind for Claude (destSubpath='skills', prefix='gsd-') + const layout = resolveRuntimeArtifactLayout('claude', configDir, 'global'); + applySurface(configDir, layout, manifest, CLUSTERS); + + // gsd-explore is in the research_ideate cluster which was disabled: + // it must be pruned from skillsDir. + assert.ok( + !fs.existsSync(gsdExplore), + 'gsd-explore/ must be removed from skills dir when research_ideate cluster is disabled' + ); + + // gsd-help is in core_loop (not disabled) and must survive. + assert.ok( + fs.existsSync(gsdHelp), + 'gsd-help/ must be preserved when its cluster is not disabled' + ); + }); + + test('(b) gsd-* dirs in retained clusters are preserved', (t) => { + const { configDir, gsdHelp } = createFixture(); + t.after(() => cleanup(configDir)); + + // Disable a cluster that does NOT include help (core_loop has help) + writeSurface(configDir, { + baseProfile: 'full', + disabledClusters: ['research_ideate'], + explicitAdds: [], + explicitRemoves: [], + }); + + const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); + const layout = resolveRuntimeArtifactLayout('claude', configDir, 'global'); + applySurface(configDir, layout, manifest, CLUSTERS); + + assert.ok( + fs.existsSync(gsdHelp), + 'gsd-help/ must be preserved — core_loop cluster remains enabled' + ); + }); + + test('(c) non-gsd user dirs are untouched', (t) => { + const { configDir, userSkill } = createFixture(); + t.after(() => cleanup(configDir)); + + writeSurface(configDir, { + baseProfile: 'full', + disabledClusters: ['research_ideate'], + explicitAdds: [], + explicitRemoves: [], + }); + + const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); + const layout = resolveRuntimeArtifactLayout('claude', configDir, 'global'); + applySurface(configDir, layout, manifest, CLUSTERS); + + assert.ok( + fs.existsSync(userSkill), + 'my-custom-skill/ (non-gsd user dir) must be preserved by applySurface' + ); + assert.ok( + fs.existsSync(path.join(userSkill, 'SKILL.md')), + 'user skill SKILL.md must be untouched' + ); + }); + + test('(d) idempotence: running applySurface twice produces identical on-disk state', (t) => { + const { configDir, skillsDir, gsdExplore, userSkill } = createFixture(); + t.after(() => cleanup(configDir)); + + writeSurface(configDir, { + baseProfile: 'full', + disabledClusters: ['research_ideate'], + explicitAdds: [], + explicitRemoves: [], + }); + + const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); + const layout = resolveRuntimeArtifactLayout('claude', configDir, 'global'); + + // First apply + applySurface(configDir, layout, manifest, CLUSTERS); + const afterFirst = fs.readdirSync(skillsDir).sort(); + + // Second apply — must produce exactly the same set + applySurface(configDir, layout, manifest, CLUSTERS); + const afterSecond = fs.readdirSync(skillsDir).sort(); + + assert.deepStrictEqual( + afterSecond, + afterFirst, + 'skills dir contents must be identical after two consecutive applySurface calls (idempotent)' + ); + + // Double-check the pruned dir is gone after both runs + assert.ok( + !fs.existsSync(gsdExplore), + 'gsd-explore/ must remain absent after second applySurface call' + ); + + // User dir must survive both runs + assert.ok( + fs.existsSync(userSkill), + 'my-custom-skill/ must survive both applySurface calls' + ); + }); + + test('(e) all-clusters-disabled: all gsd-owned dirs removed; user dirs and user gsd-* dirs survive', (t) => { + // Counter-test for Finding 1 (data-loss class) and Finding 3 (missing coverage). + // + // Disables EVERY cluster so the resolved skill set is empty. + // Assertions: + // 1. gsd-explore/ — GSD-owned, disabled cluster → REMOVED + // 2. gsd-help/ — GSD-owned, disabled cluster → REMOVED + // 3. my-custom-skill/ — user-owned, no gsd- prefix → PRESERVED + // 4. gsd-mything/ — prefix match but NOT in manifest → PRESERVED (Finding 1 fix) + const { configDir, gsdExplore, gsdHelp, userSkill, userGsdDir } = + createFixtureWithUserGsdDir(); + t.after(() => cleanup(configDir)); + + const allClusters = Object.keys(CLUSTERS); + + writeSurface(configDir, { + baseProfile: 'full', + disabledClusters: allClusters, + explicitAdds: [], + explicitRemoves: [], + }); + + const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); + const layout = resolveRuntimeArtifactLayout('claude', configDir, 'global'); + applySurface(configDir, layout, manifest, CLUSTERS); + + // 1. GSD-owned dirs in now-disabled clusters must be removed. + assert.ok( + !fs.existsSync(gsdExplore), + 'gsd-explore/ must be removed when all clusters are disabled' + ); + assert.ok( + !fs.existsSync(gsdHelp), + 'gsd-help/ must be removed when all clusters are disabled' + ); + + // 2. Non-gsd user dir must be preserved regardless. + assert.ok( + fs.existsSync(userSkill), + 'my-custom-skill/ (non-gsd user dir) must survive when all clusters are disabled' + ); + + // 3. User-created gsd-* dir NOT in the manifest must be preserved. + // This is the critical Finding 1 regression guard: without the manifest-membership + // gate, gsd-mything/ would have been silently deleted. + assert.ok( + fs.existsSync(userGsdDir), + 'gsd-mything/ (user-created gsd-* dir not in manifest) must be preserved — ' + + 'prefix match alone must not trigger deletion (Finding 1 data-loss fix)' + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-924-claude-flat-skill-layout.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-924-claude-flat-skill-layout (consolidation epic #1969 B3 #1972)", () => { +// allow-test-rule: source-text-is-the-product (see #924) +// Reads installed SKILL.md files from a real install run — +// testing their on-disk layout tests the deployed contract. + +/** + * Regression test for bug #924. + * + * PR #883 accidentally nested concrete gsd-* skills 3 levels deep for the + * Claude global install: + * + * ~/.claude/skills/gsd-ns-/skills//SKILL.md + * + * Claude Code's skills discovery scans only ONE level under ~/.claude/skills/, + * so nested concretes were never listed in the Skill-tool available-skills list. + * Direct `Skill(skill="gsd-plan-phase")` calls stopped working. + * + * Fix: revert Claude to the FLAT layout — concrete skills at the top level: + * + * ~/.claude/skills/gsd-/SKILL.md + * + * The 6 ns-* routers are also top-level entries in the flat layout (they are + * concrete skills themselves). No nested skills/ subdirs for Claude. + * + * Other 6 runtimes (cline, qwen, hermes, augment, trae, antigravity) stay nested. + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); + +const ROOT = path.join(__dirname, '..'); +const COMMANDS_GSD = path.join(ROOT, 'commands', 'gsd'); + +const { installRuntimeArtifacts } = require('../gsd-core/bin/lib/install-engine.cjs'); +const { cleanup } = require('./helpers.cjs'); +const { + loadSkillsManifest, + resolveProfile, +} = require('../gsd-core/bin/lib/install-profiles.cjs'); +const { applySurface } = require('../gsd-core/bin/lib/surface.cjs'); +const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); + +const MANIFEST = loadSkillsManifest(COMMANDS_GSD); +const RESOLVED_FULL = resolveProfile({ modes: ['full'], manifest: MANIFEST }); + +// --------------------------------------------------------------------------- +// #924 regression: Claude global install must use FLAT layout +// --------------------------------------------------------------------------- + +describe('bug-924: claude global install uses flat skill layout (concrete skills discoverable)', () => { + let tmpDir; + + before(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-924-claude-flat-')); + installRuntimeArtifacts('claude', tmpDir, 'global', RESOLVED_FULL); + }); + + after(() => { + if (tmpDir) { + try { cleanup(tmpDir); } catch { /* best-effort */ } + } + }); + + test('claude global: concrete skills are at the TOP LEVEL of skills/ (flat, directly discoverable)', () => { + const skillsDir = path.join(tmpDir, 'skills'); + assert.ok(fs.existsSync(skillsDir), `skills/ dir must exist under ${tmpDir}`); + + const topLevel = fs.readdirSync(skillsDir).filter((n) => n.startsWith('gsd-')); + + // Flat layout must have MANY more than 6 top-level gsd-* entries (concrete skills). + // Pre-#924-fix nested layout had exactly 6 (only routers). Flat must have >= 60. + assert.ok( + topLevel.length >= 60, + `Claude global must have >= 60 gsd-* top-level skill dirs (concrete flat layout). ` + + `Got ${topLevel.length}: [${topLevel.slice(0, 10).join(', ')}${topLevel.length > 10 ? ', …' : ''}]. ` + + 'Nested layout detected — #924 regression: Claude must be flat.', + ); + }); + + test('claude global: gsd-plan-phase is directly at the top level of skills/', () => { + const skillsDir = path.join(tmpDir, 'skills'); + const planPhaseDir = path.join(skillsDir, 'gsd-plan-phase'); + assert.ok( + fs.existsSync(path.join(planPhaseDir, 'SKILL.md')), + `skills/gsd-plan-phase/SKILL.md must exist at top level for Claude global install. ` + + 'Concrete skill buried in nested layout — #924 regression.', + ); + }); + + test('claude global: gsd-execute-phase is directly at the top level of skills/', () => { + const skillsDir = path.join(tmpDir, 'skills'); + assert.ok( + fs.existsSync(path.join(skillsDir, 'gsd-execute-phase', 'SKILL.md')), + `skills/gsd-execute-phase/SKILL.md must exist at top level for Claude global install.`, + ); + }); + + test('claude global: gsd-code-review is directly at the top level of skills/', () => { + const skillsDir = path.join(tmpDir, 'skills'); + assert.ok( + fs.existsSync(path.join(skillsDir, 'gsd-code-review', 'SKILL.md')), + `skills/gsd-code-review/SKILL.md must exist at top level for Claude global install.`, + ); + }); + + test('claude global: gsd-ns-workflow is at the top level as a concrete skill (no nested skills/ subdir)', () => { + const skillsDir = path.join(tmpDir, 'skills'); + const nsWorkflowDir = path.join(skillsDir, 'gsd-ns-workflow'); + assert.ok( + fs.existsSync(path.join(nsWorkflowDir, 'SKILL.md')), + `skills/gsd-ns-workflow/SKILL.md must exist at top level (router as concrete skill).`, + ); + + // In the FLAT layout, gsd-ns-workflow/ must NOT have a skills/ subdir. + // A skills/ subdir means nested layout was applied (the #924 regression). + assert.ok( + !fs.existsSync(path.join(nsWorkflowDir, 'skills')), + `skills/gsd-ns-workflow/skills/ must NOT exist in flat layout (nested layout detected — #924 regression).`, + ); + }); + + test('claude global: no concrete skill is nested under gsd-ns-*/skills//SKILL.md', () => { + const skillsDir = path.join(tmpDir, 'skills'); + const topLevel = fs.readdirSync(skillsDir).filter((n) => n.startsWith('gsd-ns-')); + + for (const nsDir of topLevel) { + const nestedSkillsDir = path.join(skillsDir, nsDir, 'skills'); + assert.ok( + !fs.existsSync(nestedSkillsDir), + `${nsDir}/skills/ must NOT exist in Claude flat layout (#924 regression: nested layout detected).`, + ); + } + }); +}); + +// --------------------------------------------------------------------------- +// #924 regression: applySurface on Claude must also preserve flat layout +// (no re-nesting after surface update) +// --------------------------------------------------------------------------- + +describe('bug-924: applySurface on claude preserves flat layout (no re-nesting)', () => { + let tmpDir; + + before(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-924-surface-')); + installRuntimeArtifacts('claude', tmpDir, 'global', RESOLVED_FULL); + }); + + after(() => { + if (tmpDir) { + try { cleanup(tmpDir); } catch { /* best-effort */ } + } + }); + + test('claude: applySurface keeps concrete skills at the top level (flat, no re-nesting)', () => { + const skillsDir = path.join(tmpDir, 'skills'); + + // Sanity: install must produce flat layout (>= 60 top-level gsd-* dirs) + const topLevelAfterInstall = fs.readdirSync(skillsDir).filter((n) => n.startsWith('gsd-')); + assert.ok( + topLevelAfterInstall.length >= 60, + `Install must produce flat layout with >= 60 gsd-* dirs. Got ${topLevelAfterInstall.length}.`, + ); + + // Run applySurface (full surface → full profile) + const layout = resolveRuntimeArtifactLayout('claude', tmpDir, 'global'); + applySurface(tmpDir, layout, MANIFEST); + + // After applySurface: still flat + const topLevelAfterSurface = fs.readdirSync(skillsDir).filter((n) => n.startsWith('gsd-')); + assert.ok( + topLevelAfterSurface.length >= 60, + `After applySurface: must still have >= 60 gsd-* top-level dirs (flat). ` + + `Got ${topLevelAfterSurface.length}. Re-nesting detected.`, + ); + + // gsd-plan-phase must remain directly accessible + assert.ok( + fs.existsSync(path.join(skillsDir, 'gsd-plan-phase', 'SKILL.md')), + 'After applySurface: gsd-plan-phase/SKILL.md must remain at top level.', + ); + }); +}); + }); +} diff --git a/tests/runtime-config-adapter-registry.test.cjs b/tests/runtime-config-adapter-registry.test.cjs index 0c1b233e8..c6327b164 100644 --- a/tests/runtime-config-adapter-registry.test.cjs +++ b/tests/runtime-config-adapter-registry.test.cjs @@ -304,3 +304,251 @@ describe('resolveInstallPlan — hooksSurface is descriptor-owned', () => { ); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-1082-install-plan-capstone.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-1082-install-plan-capstone (consolidation epic #1969 B3 #1972)", () => { +'use strict'; + +/** + * Golden-master test for resolveInstallPlan — ADR-857 phase 5g capstone. + * + * Pins the exact InstallPlan shape for all 16 runtimes to guard against + * descriptor drift. Derived from actual resolveInstallPlan output at the time + * the seam was introduced (2026-06-11). Behavioral: calls the exported + * function and asserts on typed fields — no source-grep. + */ + +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { resolveInstallPlan } = require('../gsd-core/bin/lib/runtime-config-adapter-registry.cjs'); + +// --------------------------------------------------------------------------- +// Frozen golden-master table — derived from actual resolveInstallPlan output +// --------------------------------------------------------------------------- + +const EXPECTED = { + claude: { + runtime: 'claude', + installSurface: 'settings-json', + writesSharedSettings: true, + finishPermissionWriter: null, + hookEvents: 'claude', + extendedHookEvents: ['SubagentStop', 'Stop', 'PreCompact', 'FileChanged'], + hooksSurface: 'settings-json', + sandboxTier: 'none', + }, + codex: { + runtime: 'codex', + installSurface: 'codex-toml', + writesSharedSettings: false, + finishPermissionWriter: null, + hookEvents: 'claude', + extendedHookEvents: [], + hooksSurface: 'codex-hooks-json', + sandboxTier: 'codex-agent-sandbox', + }, + antigravity: { + runtime: 'antigravity', + installSurface: 'settings-json', + writesSharedSettings: true, + finishPermissionWriter: null, + hookEvents: 'gemini', + extendedHookEvents: [], + hooksSurface: 'settings-json', + sandboxTier: 'none', + }, + gemini: { + runtime: 'gemini', + installSurface: 'settings-json', + writesSharedSettings: true, + finishPermissionWriter: null, + hookEvents: 'gemini', + extendedHookEvents: ['BeforeAgent', 'AfterAgent', 'BeforeModel'], + hooksSurface: 'settings-json', + sandboxTier: 'none', + }, + cursor: { + runtime: 'cursor', + installSurface: 'cursor-hooks-json', + writesSharedSettings: false, + finishPermissionWriter: null, + hookEvents: 'claude', + extendedHookEvents: [], + hooksSurface: 'cursor-hooks-json', + sandboxTier: 'none', + }, + opencode: { + runtime: 'opencode', + installSurface: 'settings-json', + writesSharedSettings: true, + finishPermissionWriter: 'opencode', + hookEvents: undefined, + extendedHookEvents: [], + hooksSurface: 'none', + sandboxTier: 'none', + }, + kilo: { + runtime: 'kilo', + installSurface: 'settings-json', + writesSharedSettings: false, + finishPermissionWriter: 'kilo', + hookEvents: undefined, + extendedHookEvents: [], + hooksSurface: 'none', + sandboxTier: 'none', + }, + copilot: { + runtime: 'copilot', + installSurface: 'copilot-instructions', + writesSharedSettings: false, + finishPermissionWriter: null, + hookEvents: undefined, + extendedHookEvents: [], + hooksSurface: 'copilot-inline', + sandboxTier: 'none', + }, + augment: { + runtime: 'augment', + installSurface: 'settings-json', + writesSharedSettings: true, + finishPermissionWriter: null, + hookEvents: 'claude', + extendedHookEvents: [], + hooksSurface: 'settings-json', + sandboxTier: 'none', + }, + trae: { + runtime: 'trae', + installSurface: 'profile-marker-only', + writesSharedSettings: false, + finishPermissionWriter: null, + hookEvents: undefined, + extendedHookEvents: [], + hooksSurface: 'none', + sandboxTier: 'none', + }, + qwen: { + runtime: 'qwen', + installSurface: 'settings-json', + writesSharedSettings: true, + finishPermissionWriter: null, + hookEvents: 'claude', + extendedHookEvents: ['SubagentStop', 'Stop', 'PreCompact'], + hooksSurface: 'settings-json', + sandboxTier: 'none', + }, + hermes: { + runtime: 'hermes', + installSurface: 'settings-json', + writesSharedSettings: true, + finishPermissionWriter: null, + hookEvents: 'claude', + extendedHookEvents: [], + hooksSurface: 'settings-json', + sandboxTier: 'none', + }, + codebuddy: { + runtime: 'codebuddy', + installSurface: 'settings-json', + writesSharedSettings: true, + finishPermissionWriter: null, + hookEvents: 'claude', + extendedHookEvents: [], + hooksSurface: 'settings-json', + sandboxTier: 'none', + }, + cline: { + runtime: 'cline', + installSurface: 'cline-rules', + writesSharedSettings: false, + finishPermissionWriter: null, + hookEvents: undefined, + extendedHookEvents: [], + hooksSurface: 'cline-rules', + sandboxTier: 'none', + }, + kimi: { + runtime: 'kimi', + installSurface: 'profile-marker-only', + writesSharedSettings: false, + finishPermissionWriter: null, + hookEvents: undefined, + extendedHookEvents: [], + hooksSurface: 'none', + sandboxTier: 'none', + }, + windsurf: { + runtime: 'windsurf', + installSurface: 'profile-marker-only', + writesSharedSettings: false, + finishPermissionWriter: null, + hookEvents: undefined, + extendedHookEvents: [], + hooksSurface: 'none', + sandboxTier: 'none', + }, +}; + +const ALL_RUNTIMES = Object.keys(EXPECTED); + +describe('resolveInstallPlan — ADR-857 phase 5g golden master', () => { + it('covers exactly 16 runtimes', () => { + assert.strictEqual(ALL_RUNTIMES.length, 16); + }); + + for (const runtime of ALL_RUNTIMES) { + it(`resolveInstallPlan('${runtime}') matches frozen plan`, () => { + const actual = resolveInstallPlan(runtime); + assert.deepStrictEqual(actual, EXPECTED[runtime], + `InstallPlan for '${runtime}' drifted from golden master`); + }); + } + + it('resolveInstallPlan throws TypeError for unknown runtime', () => { + assert.throws( + () => resolveInstallPlan('bogus'), + (err) => err instanceof TypeError && /bogus/.test(err.message), + ); + }); + + it('extendedHookEvents is always an array for every runtime', () => { + for (const runtime of ALL_RUNTIMES) { + const plan = resolveInstallPlan(runtime); + assert.ok(Array.isArray(plan.extendedHookEvents), + `${runtime}: extendedHookEvents should be an array`); + } + }); + + it('hooksSurface is always a non-empty string for every runtime', () => { + for (const runtime of ALL_RUNTIMES) { + const plan = resolveInstallPlan(runtime); + assert.strictEqual(typeof plan.hooksSurface, 'string', + `${runtime}: hooksSurface should be a string`); + assert.ok(plan.hooksSurface.length > 0, + `${runtime}: hooksSurface should not be empty`); + } + }); + + it('parity: resolveInstallPlan config-intent fields match resolveRuntimeConfigIntent', () => { + // Guard that resolveInstallPlan composes resolveRuntimeConfigIntent correctly — + // any drift between the two would silently break install(). + const { resolveRuntimeConfigIntent } = require('../gsd-core/bin/lib/runtime-config-adapter-registry.cjs'); + for (const runtime of ALL_RUNTIMES) { + const plan = resolveInstallPlan(runtime); + const intent = resolveRuntimeConfigIntent(runtime); + assert.strictEqual(plan.installSurface, intent.installSurface, + `${runtime}: installSurface mismatch between plan and intent`); + assert.strictEqual(plan.writesSharedSettings, intent.writesSharedSettings, + `${runtime}: writesSharedSettings mismatch`); + assert.strictEqual(plan.finishPermissionWriter, intent.finishPermissionWriter, + `${runtime}: finishPermissionWriter mismatch`); + } + }); +}); + }); +} diff --git a/tests/runtime-converters.test.cjs b/tests/runtime-converters.test.cjs index 667ec290c..54f45e410 100644 --- a/tests/runtime-converters.test.cjs +++ b/tests/runtime-converters.test.cjs @@ -476,3 +476,745 @@ describe('convertClaudeCommandToOpencodeSkill / convertClaudeCommandToKiloSkill }); } }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/feat-1173-agent-converters-descriptor.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:feat-1173-agent-converters-descriptor (consolidation epic #1969 B3 #1972)", () => { +'use strict'; + +/** + * feat-1173: Descriptor-driven agent converter wiring. + * + * Verifies that the descriptor-driven install path (dispatchKindEntry) applies + * per-runtime agent conversion when the 'agents' kind entry has a non-null + * converter — instead of silently raw-copying. + * + * Behavioral assertions: invoke the staging/dispatch seam, inspect the staged + * output files. NOT source-grep. + * + * TDD flow (REGRESSION-MUST-FAIL-FIRST rule): + * Before the fix, dispatchKindEntry ignores the converter for agents kind and + * raw-copies. The tests below prove conversion is applied by asserting that + * the staged .md contains runtime-specific frontmatter transformations absent + * in the raw source. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); + +const ROOT = path.join(__dirname, '..'); + +const { + resolveRuntimeArtifactLayoutFromRegistry, +} = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-artifact-layout.cjs')); + +const { + cleanupStagedSkills, +} = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'install-profiles.cjs')); + +const { cleanup } = require('./helpers.cjs'); + +// ─── fixtures ──────────────────────────────────────────────────────────────── + +/** + * Minimal Claude agent source with comma-separated tools (Claude format). + * Copilot conversion turns tools into a JSON array (CONV-04/05). + * Codex conversion adds block. + * Cursor/Windsurf/Augment/Trae/Codebuddy/Cline conversion strips color field. + */ +const CLAUDE_AGENT_SOURCE = `--- +name: gsd-planner +description: A GSD planning agent. +tools: Bash, Read, Write +color: blue +--- + +# GSD Planner + +This agent plans GSD phases using ~/.claude/skills. +`; + +/** + * Create a temp agents directory with a .gsd-source marker pointing back to it + * (so findAgentsSourceRoot finds the fixture dir, not the real agents/ dir). + * The .gsd-source convention expects the marker to point at a commands/gsd dir; + * agents/ is resolved as a sibling of commands/. So we set up: + * / + * commands/gsd/ (empty, satisfies the sibling check) + * agents/ + * gsd-planner.md + * .gsd-source <- points to /commands/gsd + */ +function makeFixtureRoot(agentFiles) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1173-root-')); + const commandsDir = path.join(root, 'commands', 'gsd'); + const agentsDir = path.join(root, 'agents'); + fs.mkdirSync(commandsDir, { recursive: true }); + fs.mkdirSync(agentsDir, { recursive: true }); + // .gsd-source marker must point to commands/gsd so that agentsSourceRoot resolves to agents/ + fs.writeFileSync(path.join(root, '.gsd-source'), commandsDir + '\n', 'utf8'); + for (const { name, content } of agentFiles) { + fs.writeFileSync(path.join(agentsDir, name), content, 'utf8'); + } + return root; +} + +function makeSyntheticRegistry(converterName) { + return { + runtimes: { + testruntime: { + runtime: { + artifactLayout: { + global: [ + { + kind: 'agents', + destSubpath: 'agents', + prefix: 'gsd-', + nesting: 'flat', + recursive: false, + converter: converterName, + }, + ], + local: [], + }, + }, + }, + }, + }; +} + +// ─── stageAgentsForRuntimeWithConverter unit tests ──────────────────────────── + +describe('feat-1173: stageAgentsForRuntimeWithConverter', () => { + test('is exported from install-profiles', () => { + const installProfiles = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'install-profiles.cjs')); + assert.strictEqual( + typeof installProfiles.stageAgentsForRuntimeWithConverter, + 'function', + 'stageAgentsForRuntimeWithConverter must be exported', + ); + }); + + test('applies converter to each staged agent file', (t) => { + const { stageAgentsForRuntimeWithConverter } = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'install-profiles.cjs')); + + const agentsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1173-agents-')); + fs.writeFileSync(path.join(agentsDir, 'gsd-planner.md'), CLAUDE_AGENT_SOURCE, 'utf8'); + fs.writeFileSync(path.join(agentsDir, 'gsd-executor.md'), CLAUDE_AGENT_SOURCE.replace('gsd-planner', 'gsd-executor'), 'utf8'); + + t.after(() => { + cleanup(agentsDir); + cleanupStagedSkills(); + }); + + const calls = []; + const converter = (content) => { + calls.push(content); + return content.replace('~/.claude/', '~/.copilot/'); + }; + + const resolvedProfile = { name: 'full', skills: '*', agents: new Set() }; + const stagedDir = stageAgentsForRuntimeWithConverter(agentsDir, resolvedProfile, converter); + + assert.strictEqual(calls.length, 2, 'converter called for each agent file'); + const stagedFiles = fs.readdirSync(stagedDir).sort(); + assert.deepStrictEqual(stagedFiles, ['gsd-executor.md', 'gsd-planner.md']); + + // Converter replaced ~/.claude/ with ~/.copilot/ in all staged files + for (const file of stagedFiles) { + const content = fs.readFileSync(path.join(stagedDir, file), 'utf8'); + assert.ok(!content.includes('~/.claude/'), `${file}: converter must have replaced ~/.claude/`); + assert.ok(content.includes('~/.copilot/'), `${file}: converter must have injected ~/.copilot/`); + } + }); + + test('non-existent srcAgentsDir returns srcAgentsDir unchanged', () => { + const { stageAgentsForRuntimeWithConverter } = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'install-profiles.cjs')); + const ghost = path.join(os.tmpdir(), 'gsd-1173-no-exist-' + Date.now()); + const converter = (c) => c; + const result = stageAgentsForRuntimeWithConverter(ghost, { name: 'full', skills: '*', agents: new Set() }, converter); + assert.strictEqual(result, ghost, 'must return srcAgentsDir unchanged for non-existent dir'); + }); + + test('only copies .md files (ignores non-.md)', (t) => { + const { stageAgentsForRuntimeWithConverter } = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'install-profiles.cjs')); + const agentsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1173-agents-')); + fs.writeFileSync(path.join(agentsDir, 'gsd-planner.md'), CLAUDE_AGENT_SOURCE, 'utf8'); + fs.writeFileSync(path.join(agentsDir, 'README.txt'), 'not an agent', 'utf8'); + + t.after(() => { + cleanup(agentsDir); + cleanupStagedSkills(); + }); + + const converter = (c) => c; + const resolvedProfile = { name: 'full', skills: '*', agents: new Set() }; + const stagedDir = stageAgentsForRuntimeWithConverter(agentsDir, resolvedProfile, converter); + const stagedFiles = fs.readdirSync(stagedDir); + assert.deepStrictEqual(stagedFiles, ['gsd-planner.md'], 'only .md files should be staged'); + }); +}); + +// ─── dispatchKindEntry wiring tests ────────────────────────────────────────── + +describe('feat-1173: dispatchKindEntry agents converter wiring', () => { + test('agents kind with convertClaudeAgentToCopilotAgent converter applies copilot conversion', (t) => { + const fixtureRoot = makeFixtureRoot([{ name: 'gsd-planner.md', content: CLAUDE_AGENT_SOURCE }]); + t.after(() => { + cleanup(fixtureRoot); + cleanupStagedSkills(); + }); + + const registry = makeSyntheticRegistry('convertClaudeAgentToCopilotAgent'); + const layout = resolveRuntimeArtifactLayoutFromRegistry( + registry, 'testruntime', fixtureRoot, 'global', + ); + + assert.strictEqual(layout.kinds.length, 1); + const agentKind = layout.kinds[0]; + assert.strictEqual(agentKind.kind, 'agents'); + + const resolvedProfile = { name: 'full', skills: '*', agents: new Set() }; + const stagedDir = agentKind.stage(resolvedProfile); + + const stagedFile = path.join(stagedDir, 'gsd-planner.md'); + assert.ok(fs.existsSync(stagedFile), `staged file must exist: ${stagedFile}`); + + const stagedContent = fs.readFileSync(stagedFile, 'utf8'); + + // Copilot CONV-04/05: tools converted from "Bash, Read, Write" to JSON array "['bash', 'read', 'write']" + // Raw copy would keep the original comma-separated "tools: Bash, Read, Write" line. + assert.notStrictEqual(stagedContent, CLAUDE_AGENT_SOURCE, 'converter must have transformed the content'); + assert.ok( + stagedContent.includes("tools: ['") || stagedContent.includes('tools: ['), + `Copilot conversion must produce JSON array tools. Got:\n${stagedContent.slice(0, 300)}`, + ); + }); + + test('agents kind with convertClaudeAgentToCodexAgent converter applies codex conversion', (t) => { + const fixtureRoot = makeFixtureRoot([{ name: 'gsd-planner.md', content: CLAUDE_AGENT_SOURCE }]); + t.after(() => { + cleanup(fixtureRoot); + cleanupStagedSkills(); + }); + + const registry = makeSyntheticRegistry('convertClaudeAgentToCodexAgent'); + const layout = resolveRuntimeArtifactLayoutFromRegistry( + registry, 'testruntime', fixtureRoot, 'global', + ); + + const agentKind = layout.kinds[0]; + const resolvedProfile = { name: 'full', skills: '*', agents: new Set() }; + const stagedDir = agentKind.stage(resolvedProfile); + + const stagedContent = fs.readFileSync(path.join(stagedDir, 'gsd-planner.md'), 'utf8'); + + // Codex conversion adds block + assert.notStrictEqual(stagedContent, CLAUDE_AGENT_SOURCE, 'converter must have transformed the content'); + assert.ok( + stagedContent.includes(''), + `Codex conversion must add . Got:\n${stagedContent.slice(0, 300)}`, + ); + }); + + test('agents kind with convertClaudeAgentToCursorAgent converter applies cursor conversion', (t) => { + const fixtureRoot = makeFixtureRoot([{ name: 'gsd-planner.md', content: CLAUDE_AGENT_SOURCE }]); + t.after(() => { + cleanup(fixtureRoot); + cleanupStagedSkills(); + }); + + const registry = makeSyntheticRegistry('convertClaudeAgentToCursorAgent'); + const layout = resolveRuntimeArtifactLayoutFromRegistry( + registry, 'testruntime', fixtureRoot, 'global', + ); + + const agentKind = layout.kinds[0]; + const resolvedProfile = { name: 'full', skills: '*', agents: new Set() }; + const stagedDir = agentKind.stage(resolvedProfile); + + const stagedContent = fs.readFileSync(path.join(stagedDir, 'gsd-planner.md'), 'utf8'); + + // Cursor conversion strips color field and rewrites ~/.claude/ paths + assert.notStrictEqual(stagedContent, CLAUDE_AGENT_SOURCE, 'converter must have transformed the content'); + assert.ok( + !stagedContent.includes('color:'), + `Cursor agent conversion should strip the color field. Got:\n${stagedContent.slice(0, 300)}`, + ); + }); + + test('agents kind with converter=null still raw-copies (backward compat for claude)', (t) => { + const fixtureRoot = makeFixtureRoot([{ name: 'gsd-planner.md', content: CLAUDE_AGENT_SOURCE }]); + t.after(() => { + cleanup(fixtureRoot); + cleanupStagedSkills(); + }); + + const registry = makeSyntheticRegistry(null); + const layout = resolveRuntimeArtifactLayoutFromRegistry( + registry, 'testruntime', fixtureRoot, 'global', + ); + + const agentKind = layout.kinds[0]; + const resolvedProfile = { name: 'full', skills: '*', agents: new Set() }; + const stagedDir = agentKind.stage(resolvedProfile); + + // converter=null: stageAgentsForProfile with skills='*' returns srcAgentsDir unchanged + // (no staging dir is created; the source dir IS the staged dir, a passthrough) + const stagedContent = fs.readFileSync(path.join(stagedDir, 'gsd-planner.md'), 'utf8'); + assert.strictEqual(stagedContent, CLAUDE_AGENT_SOURCE, 'converter=null must raw-copy the agent content'); + }); + + test('scope threads isGlobal to a scope-aware converter (global vs local differ)', (t) => { + // The plumbing kept by #1173 (option a): convertedAgentsKind / dispatchKindEntry + // pass the install scope to the converter as isGlobal. A scope-aware converter + // (copilot) must therefore produce different output for global vs local. This + // proves the thread is live via a synthetic descriptor — no real runtime + // declares a converted agents kind yet (declarations deferred to the ADR-1235 + // §0 parity follow-up). + const fixtureRoot = makeFixtureRoot([{ name: 'gsd-planner.md', content: CLAUDE_AGENT_SOURCE }]); + t.after(() => { + cleanup(fixtureRoot); + cleanupStagedSkills(); + }); + + const agentsEntry = { + kind: 'agents', + destSubpath: 'agents', + prefix: 'gsd-', + nesting: 'flat', + recursive: false, + converter: 'convertClaudeAgentToCopilotAgent', + }; + const registry = { + runtimes: { testruntime: { runtime: { artifactLayout: { global: [agentsEntry], local: [agentsEntry] } } } }, + }; + + const profile = { name: 'full', skills: '*', agents: new Set() }; + const stageFor = (scope) => { + const layout = resolveRuntimeArtifactLayoutFromRegistry(registry, 'testruntime', fixtureRoot, scope); + const agentKind = layout.kinds.find((k) => k.kind === 'agents'); + assert.ok(agentKind, `${scope} layout must include an agents kind`); + return fs.readFileSync(path.join(agentKind.stage(profile), 'gsd-planner.md'), 'utf8'); + }; + + const globalOut = stageFor('global'); + const localOut = stageFor('local'); + assert.notStrictEqual( + globalOut, + localOut, + 'scope-aware converter output must differ by scope — proves isGlobal is threaded from the descriptor scope', + ); + }); +}); + +// ─── real registry: claude agents kind has converter=null ──────────────────── + +describe('feat-1173: real registry claude agents kind has converter=null (backward compat)', () => { + test('claude local artifacts layout has agents entry with converter=null', () => { + const registry = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'capability-registry.cjs')); + const claudeDesc = registry.runtimes?.claude?.runtime?.artifactLayout?.local ?? []; + const agentsEntry = claudeDesc.find((e) => e.kind === 'agents'); + assert.ok(agentsEntry, 'claude local artifactLayout must have an agents entry'); + assert.strictEqual(agentsEntry.converter, null, 'claude agents entry must have converter=null'); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/fix-1515-codex-runtime-default.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:fix-1515-codex-runtime-default (consolidation epic #1969 B3 #1972)", () => { +'use strict'; +/** + * Regression tests for bug #1515: Codex install with runtime-neutral + * .planning/config.json resolves runtime as 'claude' and enables worktree + * isolation (unsafe for Codex). + * + * Root causes: + * A) config-get reads in workflows lacked --raw → output JSON-quoted → + * every comparison like [ "$RUNTIME" = "codex" ] failed silently. + * B) The conversion engine emitted --default claude for every runtime → + * neutral Codex config fell back to claude default. + * + * All tests assert on the SUT's RETURN VALUE (engine output), not raw file reads, + * except the integration test (test 4) which is explicitly the source↔engine + * parity guard and carries the allow-test-rule exemption. + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const fc = require('fast-check'); +const conversion = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs'); + +// --------------------------------------------------------------------------- +// Unit tests: engine stamps codex-specific defaults into emitted workflows +// --------------------------------------------------------------------------- + +test('codex emit stamps its own runtime default into the runtime-resolution line', () => { + const line = + 'RUNTIME=$(gsd_run query config-get runtime --default claude --raw 2>/dev/null || echo "claude")\n'; + const out = conversion._applyRuntimeRewrites(line, 'codex', '$HOME/.codex/', true, undefined); + assert.ok( + out.includes('config-get runtime --default codex --raw'), + `Expected 'config-get runtime --default codex --raw' in output; got:\n${out}`, + ); + assert.ok( + out.includes('|| echo "codex")'), + `Expected '|| echo "codex")' in output; got:\n${out}`, + ); + assert.ok( + !out.includes('--default claude'), + `Expected '--default claude' to be fully rewritten; got:\n${out}`, + ); + assert.ok( + !out.includes('echo "claude"'), + `Expected 'echo "claude"' to be fully rewritten; got:\n${out}`, + ); +}); + +test('codex emit defaults workflow.use_worktrees to false', () => { + const line = + 'USE_WORKTREES=$(gsd_run query config-get workflow.use_worktrees --raw 2>/dev/null || echo "true")\n'; + const out = conversion._applyRuntimeRewrites(line, 'codex', '$HOME/.codex/', true, undefined); + assert.ok( + out.includes('config-get workflow.use_worktrees --default false --raw'), + `Expected 'config-get workflow.use_worktrees --default false --raw' in output; got:\n${out}`, + ); + assert.ok( + out.includes('|| echo "false")'), + `Expected '|| echo "false")' in output; got:\n${out}`, + ); + assert.ok( + !out.includes('|| echo "true")'), + `Expected '|| echo "true")' to be fully rewritten; got:\n${out}`, + ); +}); + +test('claude runtime does NOT rewrite the runtime default — stamping is non-claude-scoped (#1521 inversion)', () => { + // #1521 generalizes stamping to ALL non-Claude runtimes. The negative case + // (no stamping) is now the 'claude' runtime, not other non-Claude runtimes. + const line = + 'RUNTIME=$(gsd_run query config-get runtime --default claude --raw 2>/dev/null || echo "claude")\n'; + const out = conversion._applyRuntimeRewrites(line, 'claude', '$HOME/.claude/', true, undefined); + assert.ok( + out.includes('--default claude --raw'), + `Expected claude output to preserve '--default claude --raw'; got:\n${out}`, + ); + assert.ok( + !out.includes('--default codex'), + `Expected claude output NOT to contain '--default codex'; got:\n${out}`, + ); +}); + +// --------------------------------------------------------------------------- +// Integration / parity guard: real source ↔ engine output for codex (all surfaces) +// --------------------------------------------------------------------------- + +test('regression: every edited workflow gets codex-stamped (source↔engine parity, all surfaces) (#1515)', () => { + // allow-test-rule: emitted workflow runtime-resolution shell block is the runtime contract surface (#1515) — asserts on engine-transformed output of the real source + const WORKFLOWS = ['execute-phase.md', 'autonomous.md', 'manager.md', 'diagnose-issues.md', 'quick.md']; + const CLAUDE_RUNTIME = 'config-get runtime --default claude --raw 2>/dev/null || echo "claude"'; + const CODEX_RUNTIME = 'config-get runtime --default codex --raw 2>/dev/null || echo "codex"'; + const TRUE_WT = 'config-get workflow.use_worktrees --raw 2>/dev/null || echo "true"'; + const FALSE_WT = 'config-get workflow.use_worktrees --default false --raw 2>/dev/null || echo "false"'; + for (const wf of WORKFLOWS) { + const src = fs.readFileSync(path.join(__dirname, '..', 'gsd-core', 'workflows', wf), 'utf8'); + const out = conversion._applyRuntimeRewrites(src, 'codex', '$HOME/.codex/', true, undefined); + // No un-stamped claude/true resolution line may survive codex emit on ANY surface. + assert.ok(!out.includes(CLAUDE_RUNTIME), `${wf}: residual un-stamped runtime read — engine regex no longer matches source line (parity drift)`); + assert.ok(!out.includes(TRUE_WT), `${wf}: residual un-stamped use_worktrees read — parity drift`); + // If the source HAS such a read, the codex form must be present. + if (src.includes(CLAUDE_RUNTIME)) assert.ok(out.includes(CODEX_RUNTIME), `${wf}: runtime read not stamped to codex`); + if (src.includes(TRUE_WT)) assert.ok(out.includes(FALSE_WT), `${wf}: use_worktrees read not defaulted to false`); + } +}); + +// --------------------------------------------------------------------------- +// Property tests (RULESET.TESTS.property-based-testing) +// --------------------------------------------------------------------------- + +test('property: runtime stamping applies for ALL non-claude runtimes; only claude leaves --default claude unchanged (#1521)', () => { + // #1521: generalised from codex-only to all non-claude runtimes. + // Use the canonical list from the conversion module to avoid hand-rolled array drift. + const { NON_CLAUDE_RUNTIMES } = conversion; + const RUNTIMES = ['claude', ...NON_CLAUDE_RUNTIMES]; + const line = 'RUNTIME=$(gsd_run query config-get runtime --default claude --raw 2>/dev/null || echo "claude")\n'; + fc.assert(fc.property(fc.constantFrom(...RUNTIMES), (rt) => { + const out = conversion._applyRuntimeRewrites(line, rt, `$HOME/.${rt}/`, true, undefined); + return rt === 'claude' + ? out.includes('--default claude --raw') && !out.includes('--default codex') + : out.includes(`--default ${rt} --raw`) && !out.includes('--default claude'); + })); +}); + +test('property: codex stamping is idempotent on resolution lines (#1515)', () => { + fc.assert(fc.property(fc.constantFrom('runtime', 'use_worktrees'), (which) => { + const line = which === 'runtime' + ? 'RUNTIME=$(gsd_run query config-get runtime --default claude --raw 2>/dev/null || echo "claude")\n' + : 'USE_WORKTREES=$(gsd_run query config-get workflow.use_worktrees --raw 2>/dev/null || echo "true")\n'; + const once = conversion._applyRuntimeRewrites(line, 'codex', '$HOME/.codex/', true, undefined); + const twice = conversion._applyRuntimeRewrites(once, 'codex', '$HOME/.codex/', true, undefined); + return once === twice; + })); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/fix-1521-non-claude-runtime-default-resolution.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:fix-1521-non-claude-runtime-default-resolution (consolidation epic #1969 B3 #1972)", () => { +'use strict'; +/** + * Regression tests for #1521: every non-Claude runtime stamps its own runtime + * identity + workflow.use_worktrees=false into emitted workflows. + * + * GSD's worktree isolation relies on Claude Code's isolation="worktree" spawn + * parameter, which no other runtime honors. #1519 (Codex-only fix) is + * generalized here to ALL non-Claude runtimes. + * + * All tests assert on the SUT's RETURN VALUE (engine output), not raw file reads, + * except the parity integration test which carries the allow-test-rule exemption. + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const fc = require('fast-check'); +const conversion = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs'); + +// #1521: use the canonical list from the conversion module rather than a hand-rolled +// local array that can drift from the real runtime set. +const { NON_CLAUDE_RUNTIMES: NON_CLAUDE } = conversion; +const WORKFLOWS = [ + 'execute-phase.md', 'autonomous.md', 'manager.md', 'diagnose-issues.md', 'quick.md', +]; + +const CLAUDE_RUNTIME_LINE = 'config-get runtime --default claude --raw 2>/dev/null || echo "claude"'; +const TRUE_WT_LINE = 'config-get workflow.use_worktrees --raw 2>/dev/null || echo "true"'; +const FALSE_WT_LINE = 'config-get workflow.use_worktrees --default false --raw 2>/dev/null || echo "false"'; + +// --------------------------------------------------------------------------- +// Parity across ALL non-Claude runtimes × all 5 workflows +// --------------------------------------------------------------------------- + +test('parity: every non-Claude runtime stamps its own runtime default and use_worktrees=false on all workflows (#1521)', () => { + // allow-test-rule: emitted workflow runtime-resolution shell block is the runtime contract surface (#1521) + for (const rt of NON_CLAUDE) { + for (const wf of WORKFLOWS) { + const src = fs.readFileSync( + path.join(__dirname, '..', 'gsd-core', 'workflows', wf), + 'utf8', + ); + const out = conversion._applyRuntimeRewrites(src, rt, `$HOME/.${rt}/`, true, undefined); + + // No un-stamped claude runtime line may survive + assert.ok( + !out.includes(CLAUDE_RUNTIME_LINE), + `${rt}/${wf}: residual un-stamped claude runtime read — _stampNonClaudeRuntimeDefaults not applied`, + ); + + // No un-stamped use_worktrees=true line may survive + assert.ok( + !out.includes(TRUE_WT_LINE), + `${rt}/${wf}: residual un-stamped use_worktrees=true read — _stampNonClaudeRuntimeDefaults not applied`, + ); + + // If the source had a runtime read, the output must have --default + if (src.includes(CLAUDE_RUNTIME_LINE)) { + assert.ok( + out.includes(`config-get runtime --default ${rt} --raw 2>/dev/null || echo "${rt}"`), + `${rt}/${wf}: runtime line not stamped to --default ${rt}`, + ); + } + + // If the source had a use_worktrees read, the output must have --default false + if (src.includes(TRUE_WT_LINE)) { + assert.ok( + out.includes(FALSE_WT_LINE), + `${rt}/${wf}: use_worktrees line not defaulted to false`, + ); + } + } + } +}); + +// --------------------------------------------------------------------------- +// Claude unchanged — no stamping for the native runtime +// --------------------------------------------------------------------------- + +test('claude runtime leaves runtime default and use_worktrees=true unchanged (#1521)', () => { + const src = fs.readFileSync( + path.join(__dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md'), + 'utf8', + ); + const out = conversion._applyRuntimeRewrites(src, 'claude', '$HOME/.claude/', true, undefined); + + // Claude emit must preserve the original --default claude line + if (src.includes(CLAUDE_RUNTIME_LINE)) { + assert.ok( + out.includes(CLAUDE_RUNTIME_LINE), + `claude/execute-phase.md: expected original claude runtime line to survive; got mutated`, + ); + } + + // Claude emit must NOT gain --default false for use_worktrees + assert.ok( + !out.includes(FALSE_WT_LINE), + `claude/execute-phase.md: use_worktrees line must NOT be stamped false for claude runtime`, + ); +}); + +// --------------------------------------------------------------------------- +// fc property — identity: each runtime stamps itself, claude stays unchanged +// --------------------------------------------------------------------------- + +test('property: _stampNonClaudeRuntimeDefaults stamps each non-claude runtime and leaves claude unchanged (#1521)', () => { + const line = + 'RUNTIME=$(gsd_run query config-get runtime --default claude --raw 2>/dev/null || echo "claude")\n'; + fc.assert( + fc.property(fc.constantFrom(...NON_CLAUDE, 'claude'), (rt) => { + const out = conversion._applyRuntimeRewrites(line, rt, `$HOME/.${rt}/`, true, undefined); + if (rt === 'claude') { + return out.includes('--default claude') && !/--default (?!claude)/.test(out); + } + return out.includes(`--default ${rt}`) && !out.includes('--default claude'); + }), + ); +}); + +// --------------------------------------------------------------------------- +// fc property — idempotence: stamping twice equals once +// --------------------------------------------------------------------------- + +test('property: _stampNonClaudeRuntimeDefaults is idempotent (#1521)', () => { + fc.assert( + fc.property( + fc.constantFrom(...NON_CLAUDE), + fc.constantFrom('runtime', 'use_worktrees'), + (rt, which) => { + const line = + which === 'runtime' + ? 'RUNTIME=$(gsd_run query config-get runtime --default claude --raw 2>/dev/null || echo "claude")\n' + : 'USE_WORKTREES=$(gsd_run query config-get workflow.use_worktrees --raw 2>/dev/null || echo "true")\n'; + const once = conversion._applyRuntimeRewrites(line, rt, `$HOME/.${rt}/`, true, undefined); + const twice = conversion._applyRuntimeRewrites(once, rt, `$HOME/.${rt}/`, true, undefined); + return once === twice; + }, + ), + ); +}); + +// --------------------------------------------------------------------------- +// Guard generalization: execute-phase.md uses != "claude" not = "codex" +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Guard generalization: execute-phase.md, quick.md, and diagnose-issues.md +// all use != "claude" (not = "codex") for the worktree guard (#1521) +// --------------------------------------------------------------------------- + +test('execute-phase.md, quick.md, and diagnose-issues.md guards are generalized to != "claude" (not Codex-specific) (#1521)', () => { + // allow-test-rule: emitted workflow runtime-resolution shell block is the runtime contract surface (#1521) + const GUARD_WORKFLOWS = ['execute-phase.md', 'quick.md', 'diagnose-issues.md']; + for (const wf of GUARD_WORKFLOWS) { + const src = fs.readFileSync( + path.join(__dirname, '..', 'gsd-core', 'workflows', wf), + 'utf8', + ); + assert.ok( + src.includes('[ "$RUNTIME" != "claude" ] && [ "$USE_WORKTREES" != "false" ]'), + `${wf}: expected generalized guard [ "$RUNTIME" != "claude" ] && [ "$USE_WORKTREES" != "false" ]`, + ); + assert.ok( + !src.includes('[ "$RUNTIME" = "codex" ] && [ "$USE_WORKTREES" != "false" ]'), + `${wf}: found Codex-specific guard — should have been generalized to != "claude"`, + ); + } +}); + +// --------------------------------------------------------------------------- +// Orchestration gating: manager.md + autonomous.md gate background dispatch on +// the typed FLATTEN query. #1708 (ADR-1239 Phase B) graduated #1521's +// codex-specific check to a documentation-sourced shouldFlattenDispatch — the +// prose now branches on `FLATTEN` (false = background), not a runtime name. +// --------------------------------------------------------------------------- + +test('manager.md and autonomous.md gate run_in_background on FLATTEN=false, not a runtime name (#1521, graduated by #1708)', () => { + // allow-test-rule: orchestration dispatch gating in manager/autonomous .md is the runtime contract surface (#1521/#1708) + const manager = fs.readFileSync( + path.join(__dirname, '..', 'gsd-core', 'workflows', 'manager.md'), + 'utf8', + ); + const autonomous = fs.readFileSync( + path.join(__dirname, '..', 'gsd-core', 'workflows', 'autonomous.md'), + 'utf8', + ); + + // Both files must gate run_in_background on the typed FLATTEN decision (not a runtime name) + assert.ok( + /If `FLATTEN` is `false`[\s\S]{0,500}?run_in_background=true/.test(manager), + 'manager.md: expected run_in_background dispatch gated on FLATTEN=false (typed dispatch-should-flatten query)', + ); + assert.ok( + /If `FLATTEN` is `false`[\s\S]{0,1200}?run_in_background=true/.test(autonomous), + 'autonomous.md: expected run_in_background dispatch gated on FLATTEN=false (typed dispatch-should-flatten query)', + ); + + // Inline is the else branch, keyed on FLATTEN — never a runtime name + assert.ok( + /Otherwise[\s\S]{0,250}?inline/i.test(manager), + 'manager.md: expected "Otherwise ... inline" branch keyed on FLATTEN', + ); + assert.ok( + /Otherwise[\s\S]{0,250}?inline/i.test(autonomous), + 'autonomous.md: expected "Otherwise ... inline" branch keyed on FLATTEN', + ); + // And the old runtime-name gating must be gone (no `RUNTIME` is `codex` dispatch gate) + assert.ok( + !/`RUNTIME` is `codex`[\s\S]{0,500}?run_in_background=true/.test(manager), + 'manager.md: must no longer gate run_in_background on the runtime name', + ); +}); + +test('manager.md and autonomous.md no longer contain old "not claude" background-dispatch gating (#1521)', () => { + // allow-test-rule: orchestration dispatch gating in manager/autonomous .md is the runtime contract surface (#1521) + const manager = fs.readFileSync( + path.join(__dirname, '..', 'gsd-core', 'workflows', 'manager.md'), + 'utf8', + ); + const autonomous = fs.readFileSync( + path.join(__dirname, '..', 'gsd-core', 'workflows', 'autonomous.md'), + 'utf8', + ); + + // The old phrasing that unconditionally sent every non-claude runtime to background must be gone + assert.ok( + !manager.includes('If `RUNTIME` is not `claude` (e.g. Codex)'), + 'manager.md: old "If `RUNTIME` is not `claude` (e.g. Codex)" gating must be replaced', + ); + assert.ok( + !autonomous.includes('On other runtimes:'), + 'autonomous.md: old "On other runtimes:" branch label must be replaced', + ); +}); + }); +} diff --git a/tests/runtime-homes-descriptor-drive.test.cjs b/tests/runtime-homes-descriptor-drive.test.cjs index 6c04af418..41555f415 100644 --- a/tests/runtime-homes-descriptor-drive.test.cjs +++ b/tests/runtime-homes-descriptor-drive.test.cjs @@ -872,3 +872,359 @@ describe('descriptor-driven parity: 14 non-probe registry runtimes × no-env-var }); } }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3126-global-skills-base-runtime-path.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3126-global-skills-base-runtime-path (consolidation epic #1969 B3 #1972)", () => { +'use strict'; +// allow-test-rule: last three tests read init.cjs source to verify delegation contract to runtime-homes.cjs — structural guard, no behavioral IR exposed (see #3126) + +// Regression guard for bug #3126. +// +// buildAgentSkillsBlock() in init.cjs hardcoded `globalSkillsBase` to +// `~/.claude/skills` regardless of the active runtime. On a Cursor install, +// global: skills live under `~/.cursor/skills`, causing every global: lookup +// to silently fail with: +// [agent-skills] WARNING: Global skill not found at "~/.cursor/skills/X/SKILL.md" — skipping +// +// Fix introduces gsd-core/bin/lib/runtime-homes.cjs with first-class +// support for all 15 supported runtimes, including: +// - hermes: nested skills/gsd// layout (#2841) +// - cline: rules-based, returns null (no skills directory) +// - CLAUDE_CONFIG_DIR env var for Claude (was missing) +// - All other runtime-specific env vars + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const os = require('node:os'); +const { cleanup } = require('./helpers.cjs'); + +const ROOT = path.join(__dirname, '..'); +const { + getGlobalConfigDir, + getGlobalSkillsBase, + getGlobalSkillDir, +} = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-homes.cjs')); + +// Helper: run fn with an env var temporarily set +function withEnv(key, value, fn) { + const orig = process.env[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + try { return fn(); } + finally { + if (orig === undefined) delete process.env[key]; + else process.env[key] = orig; + } +} + +describe('bug #3126: runtime-homes getGlobalConfigDir — defaults', () => { + const defaults = [ + ['claude', path.join(os.homedir(), '.claude')], + ['cursor', path.join(os.homedir(), '.cursor')], + ['gemini', path.join(os.homedir(), '.gemini')], + ['codex', path.join(os.homedir(), '.codex')], + ['copilot', path.join(os.homedir(), '.copilot')], + ['antigravity', path.join(os.homedir(), '.gemini', 'antigravity')], + ['windsurf', path.join(os.homedir(), '.codeium', 'windsurf')], + ['augment', path.join(os.homedir(), '.augment')], + ['trae', path.join(os.homedir(), '.trae')], + ['qwen', path.join(os.homedir(), '.qwen')], + ['hermes', path.join(os.homedir(), '.hermes')], + ['codebuddy', path.join(os.homedir(), '.codebuddy')], + ['cline', path.join(os.homedir(), '.cline')], + ['opencode', path.join(os.homedir(), '.config', 'opencode')], + ['kilo', path.join(os.homedir(), '.config', 'kilo')], + ]; + for (const [runtime, expected] of defaults) { + test(`${runtime} default configDir`, () => { + // Derive env-var list from the registry so new runtimes are auto-covered. + // GROK_AGENTS_HOME is kept explicitly (grok has no registry entry). + const { runtimes: _reg3126 } = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'capability-registry.cjs')); + const _regEnvKeys3126 = Object.values(_reg3126).flatMap((r) => { + const ch = r.runtime?.configHome; + if (!ch) return []; + const envs = Array.isArray(ch.env) ? ch.env : []; + const skillsEnvs = ch.skillsHome && Array.isArray(ch.skillsHome.env) ? ch.skillsHome.env : []; + return [...envs, ...skillsEnvs]; + }); + const envKeys = [...new Set([..._regEnvKeys3126, 'GROK_AGENTS_HOME', 'XDG_CONFIG_HOME'])]; + const saved = {}; + for (const k of envKeys) { saved[k] = process.env[k]; delete process.env[k]; } + try { + assert.strictEqual(getGlobalConfigDir(runtime), expected); + } finally { + for (const k of envKeys) { + if (saved[k] !== undefined) process.env[k] = saved[k]; + } + } + }); + } + test('unknown runtime falls back to ~/.claude', () => { + withEnv('CLAUDE_CONFIG_DIR', undefined, () => { + assert.strictEqual(getGlobalConfigDir('unknown-xyz'), path.join(os.homedir(), '.claude')); + }); + }); +}); + +describe('bug #3126: runtime-homes env-var overrides', () => { + test('claude respects CLAUDE_CONFIG_DIR (was missing in old code)', () => { + withEnv('CLAUDE_CONFIG_DIR', '/custom/claude', () => { + assert.strictEqual(String(getGlobalConfigDir('claude')).replace(/\\/g, '/'), '/custom/claude'); + }); + }); + test('cursor respects CURSOR_CONFIG_DIR', () => { + withEnv('CURSOR_CONFIG_DIR', '/custom/cursor', () => { + assert.strictEqual(String(getGlobalConfigDir('cursor')).replace(/\\/g, '/'), '/custom/cursor'); + }); + }); + test('opencode respects OPENCODE_CONFIG_DIR', () => { + withEnv('OPENCODE_CONFIG_DIR', '/custom/opencode', () => { + withEnv('XDG_CONFIG_HOME', undefined, () => { + assert.strictEqual(String(getGlobalConfigDir('opencode')).replace(/\\/g, '/'), '/custom/opencode'); + }); + }); + }); + test('opencode uses XDG_CONFIG_HOME when OPENCODE_CONFIG_DIR absent', () => { + withEnv('OPENCODE_CONFIG_DIR', undefined, () => { + withEnv('OPENCODE_CONFIG', undefined, () => { + withEnv('XDG_CONFIG_HOME', '/xdg', () => { + assert.strictEqual(getGlobalConfigDir('opencode'), path.join('/xdg', 'opencode')); + }); + }); + }); + }); + test('kilo uses XDG_CONFIG_HOME when KILO_CONFIG_DIR absent', () => { + withEnv('KILO_CONFIG_DIR', undefined, () => { + withEnv('KILO_CONFIG', undefined, () => { + withEnv('XDG_CONFIG_HOME', '/xdg', () => { + assert.strictEqual(getGlobalConfigDir('kilo'), path.join('/xdg', 'kilo')); + }); + }); + }); + }); + + test('antigravity detects 2.x IDE dir when legacy dir is absent', () => { + const home = require('node:fs').mkdtempSync(path.join(os.tmpdir(), 'gsd-antigravity-home-')); + try { + require('node:fs').mkdirSync(path.join(home, '.gemini', 'antigravity-ide'), { recursive: true }); + const savedHome = process.env.HOME; + const savedUserProfile = process.env.USERPROFILE; + process.env.HOME = home; + process.env.USERPROFILE = home; + withEnv('ANTIGRAVITY_CONFIG_DIR', undefined, () => { + assert.strictEqual( + getGlobalConfigDir('antigravity'), + path.join(home, '.gemini', 'antigravity-ide'), + ); + }); + if (savedHome === undefined) delete process.env.HOME; + else process.env.HOME = savedHome; + if (savedUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = savedUserProfile; + } finally { + cleanup(home); + } + }); +}); + +describe('bug #3126: runtime-homes getGlobalSkillsBase', () => { + test('most runtimes: skills at /skills', () => { + withEnv('CURSOR_CONFIG_DIR', undefined, () => { + assert.strictEqual( + getGlobalSkillsBase('cursor'), + path.join(os.homedir(), '.cursor', 'skills'), + ); + }); + }); + test('hermes: skills at /skills/gsd (nested layout #2841)', () => { + withEnv('HERMES_HOME', undefined, () => { + assert.strictEqual( + getGlobalSkillsBase('hermes'), + path.join(os.homedir(), '.hermes', 'skills', 'gsd'), + ); + }); + }); + test('cline: returns ~/.cline/skills (skills-capable since v3.48.0 — #782)', () => { + withEnv('CLINE_CONFIG_DIR', undefined, () => { + assert.strictEqual( + getGlobalSkillsBase('cline'), + path.join(os.homedir(), '.cline', 'skills'), + ); + }); + }); +}); + +describe('bug #3126: runtime-homes getGlobalSkillDir', () => { + test('cursor: /skills/', () => { + withEnv('CURSOR_CONFIG_DIR', undefined, () => { + assert.strictEqual( + getGlobalSkillDir('cursor', 'gsd-executor'), + path.join(os.homedir(), '.cursor', 'skills', 'gsd-executor'), + ); + }); + }); + test('hermes: /skills/gsd/', () => { + withEnv('HERMES_HOME', undefined, () => { + assert.strictEqual( + getGlobalSkillDir('hermes', 'gsd-executor'), + path.join(os.homedir(), '.hermes', 'skills', 'gsd', 'gsd-executor'), + ); + }); + }); + test('cline: returns ~/.cline/skills/gsd-executor (skills-capable since v3.48.0 — #782)', () => { + withEnv('CLINE_CONFIG_DIR', undefined, () => { + assert.strictEqual( + getGlobalSkillDir('cline', 'gsd-executor'), + path.join(os.homedir(), '.cline', 'skills', 'gsd-executor'), + ); + }); + }); +}); + +describe('getGlobalConfigDir — explicitDir override and opencode/kilo file-path precedence', () => { + // ── explicitDir override ────────────────────────────────────────────────── + test('explicitDir absolute path is returned as-is (claude)', () => { + assert.strictEqual(String(getGlobalConfigDir('claude', '/tmp/x')).replace(/\\/g, '/'), '/tmp/x'); + }); + + test('explicitDir with tilde is expanded (opencode)', () => { + assert.strictEqual( + getGlobalConfigDir('opencode', '~/foo'), + path.join(os.homedir(), 'foo'), + ); + }); + + test('explicitDir wins even when OPENCODE_CONFIG_DIR is also set', () => { + withEnv('OPENCODE_CONFIG_DIR', '/should/not/win', () => { + assert.strictEqual(String(getGlobalConfigDir('opencode', '/explicit/wins')).replace(/\\/g, '/'), '/explicit/wins'); + }); + }); + + // ── opencode: OPENCODE_CONFIG file-path step ────────────────────────────── + test('opencode: OPENCODE_CONFIG → path.dirname(expandTilde(value))', () => { + withEnv('OPENCODE_CONFIG_DIR', undefined, () => { + withEnv('XDG_CONFIG_HOME', undefined, () => { + withEnv('OPENCODE_CONFIG', '/home/u/cfg/opencode.json', () => { + assert.strictEqual(String(getGlobalConfigDir('opencode')).replace(/\\/g, '/'), '/home/u/cfg'); + }); + }); + }); + }); + + test('opencode: OPENCODE_CONFIG_DIR takes precedence over OPENCODE_CONFIG', () => { + withEnv('OPENCODE_CONFIG_DIR', '/dir/wins', () => { + withEnv('OPENCODE_CONFIG', '/file/loses.json', () => { + assert.strictEqual(String(getGlobalConfigDir('opencode')).replace(/\\/g, '/'), '/dir/wins'); + }); + }); + }); + + test('opencode: OPENCODE_CONFIG takes precedence over XDG_CONFIG_HOME', () => { + withEnv('OPENCODE_CONFIG_DIR', undefined, () => { + withEnv('OPENCODE_CONFIG', '/cfg/opencode.json', () => { + withEnv('XDG_CONFIG_HOME', '/xdg/should/lose', () => { + assert.strictEqual(String(getGlobalConfigDir('opencode')).replace(/\\/g, '/'), '/cfg'); + }); + }); + }); + }); + + test('opencode: default ~/.config/opencode when no env vars set', () => { + withEnv('OPENCODE_CONFIG_DIR', undefined, () => { + withEnv('OPENCODE_CONFIG', undefined, () => { + withEnv('XDG_CONFIG_HOME', undefined, () => { + assert.strictEqual( + getGlobalConfigDir('opencode'), + path.join(os.homedir(), '.config', 'opencode'), + ); + }); + }); + }); + }); + + // ── kilo: KILO_CONFIG file-path step ───────────────────────────────────── + test('kilo: KILO_CONFIG → path.dirname(expandTilde(value))', () => { + withEnv('KILO_CONFIG_DIR', undefined, () => { + withEnv('XDG_CONFIG_HOME', undefined, () => { + withEnv('KILO_CONFIG', '/home/u/cfg/kilo.json', () => { + assert.strictEqual(String(getGlobalConfigDir('kilo')).replace(/\\/g, '/'), '/home/u/cfg'); + }); + }); + }); + }); + + test('kilo: KILO_CONFIG_DIR takes precedence over KILO_CONFIG', () => { + withEnv('KILO_CONFIG_DIR', '/dir/wins', () => { + withEnv('KILO_CONFIG', '/file/loses.json', () => { + assert.strictEqual(String(getGlobalConfigDir('kilo')).replace(/\\/g, '/'), '/dir/wins'); + }); + }); + }); + + test('kilo: KILO_CONFIG takes precedence over XDG_CONFIG_HOME', () => { + withEnv('KILO_CONFIG_DIR', undefined, () => { + withEnv('KILO_CONFIG', '/cfg/kilo.json', () => { + withEnv('XDG_CONFIG_HOME', '/xdg/should/lose', () => { + assert.strictEqual(String(getGlobalConfigDir('kilo')).replace(/\\/g, '/'), '/cfg'); + }); + }); + }); + }); + + test('kilo: default ~/.config/kilo when no env vars set', () => { + withEnv('KILO_CONFIG_DIR', undefined, () => { + withEnv('KILO_CONFIG', undefined, () => { + withEnv('XDG_CONFIG_HOME', undefined, () => { + assert.strictEqual( + getGlobalConfigDir('kilo'), + path.join(os.homedir(), '.config', 'kilo'), + ); + }); + }); + }); + }); +}); + +describe('bug #3126: init.cjs uses runtime-homes not hardcoded .claude', () => { + test('init.cjs has no hardcoded globalSkillsBase assignment to ~/.claude/skills', () => { + const fs = require('node:fs'); + const src = fs.readFileSync( + path.join(ROOT, 'gsd-core', 'bin', 'lib', 'init.cjs'), + 'utf8', + ); + assert.ok( + !src.includes("const globalSkillsBase = path.join(os.homedir(), '.claude', 'skills')"), + 'init.cjs still assigns globalSkillsBase to hardcoded ~/.claude/skills — fix not applied', + ); + }); + test('init.cjs requires runtime-homes', () => { + const fs = require('node:fs'); + const src = fs.readFileSync( + path.join(ROOT, 'gsd-core', 'bin', 'lib', 'init.cjs'), + 'utf8', + ); + assert.ok( + src.includes('runtime-homes'), + 'init.cjs does not require runtime-homes.cjs', + ); + }); + test('init.cjs warning message no longer hardcodes ~/.claude/skills', () => { + const fs = require('node:fs'); + const src = fs.readFileSync( + path.join(ROOT, 'gsd-core', 'bin', 'lib', 'init.cjs'), + 'utf8', + ); + assert.ok( + !src.includes("~/.claude/skills/${skillName}/SKILL.md"), + 'init.cjs warning message still hardcodes ~/.claude/skills path', + ); + }); +}); + }); +} diff --git a/tests/runtime-homes.property.test.cjs b/tests/runtime-homes.property.test.cjs index 73a436d2f..9e38ddf5f 100644 --- a/tests/runtime-homes.property.test.cjs +++ b/tests/runtime-homes.property.test.cjs @@ -125,3 +125,195 @@ describe('runtime-homes: dot-home-nested probe resolution properties', () => { ); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-1676-path-prefix-collapse-idempotency.property.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-1676-path-prefix-collapse-idempotency.property (consolidation epic #1969 B3 #1972)", () => { +'use strict'; + +/** + * Property-based tests for the relocated rewrite engine (ADR-1508 Phase 2). + * + * Module: gsd-core/bin/lib/runtime-artifact-conversion.cjs + * Exports under test: + * - _computePathPrefix (private; the path-prefix derivation owner) + * - _applyRuntimeRewrites (the per-runtime content-rewrite engine) + * + * Issue #1676 (epic #1507 / ADR-1508): delivers the fast-check property + * coverage promised in #1511's test scope but not landed there. + * + * Properties under test: + * (A) $HOME-collapse invariant — a global install whose target lives under + * $HOME (and is not opencode) MUST project to a `$HOME//` + * prefix, never the resolved absolute homedir path. opencode is the + * documented exception (it uses ~/.config/opencode, which breaks the + * $HOME shorthand inside double-quoted content). Asserted by EXACT + * equality (not substring) so short homes like `/root` or `/a` do not + * false-positive — the same trap handled at + * tests/path-replacement.test.cjs:38-47. + * (B) backslash→posix invariance (#1615 Windows path-leak fix): feeding + * Windows backslash paths yields the same prefix as their posix form. + * (C) path-rewrite idempotency — applying the engine twice yields the same + * bytes as once (no double-prefixing, no `$HOME`-of-`$HOME`). + * attribution is held at undefined to isolate the path-rewrite axis. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const fc = require('./helpers/fast-check-setup.cjs'); + +const conversion = require( + path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'runtime-artifact-conversion.cjs'), +); +const { getDirName } = require( + path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'runtime-name-policy.cjs'), +); + +// Runtimes whose _applyRuntimeRewrites branch performs real path rewrites +// (~/.claude/ → pathPrefix). copilot/antigravity only do attribution; claude +// and the gemini/kilo family are omitted to keep the property on the path axis +// the issue names. getDirName(`.${rt}`) == `.${rt}` for every entry here. +const PATH_REWRITE_RUNTIMES = ['codex', 'cline', 'cursor', 'windsurf', 'augment', 'trae', 'codebuddy']; + +const HOMES = ['/home/u', '/Users/x', '/root', '/srv/app', '/a']; +const SEG = fc.stringMatching(/^[a-z][a-z0-9]{0,8}$/); + +// Build a posix `home/` target with no leading/trailing/double slashes. +const target = (home, segs) => `${home}/${segs.join('/')}`; + +describe('_computePathPrefix: $HOME-collapse invariant (#1676 / ADR-1508)', () => { + test('property: global-under-home collapses to $HOME// unless opencode', () => { + fc.assert( + fc.property( + fc.record({ + home: fc.constantFrom(...HOMES), + segs: fc.array(SEG, { minLength: 1, maxLength: 3 }), + isOpencode: fc.boolean(), + }), + ({ home, segs, isOpencode }) => { + const resolvedTarget = target(home, segs); + const suffix = segs.join('/'); + const prefix = conversion._computePathPrefix({ + isGlobal: true, + isOpencode, + isWindowsHost: false, + resolvedTarget, + homeDir: home, + }); + if (!isOpencode) { + // Collapse: exact $HOME form, never the resolved homedir path. + assert.equal(prefix, `$HOME/${suffix}/`); + assert.ok(prefix.startsWith('$HOME/'), 'prefix must start with $HOME token'); + } else { + // opencode: absolute resolved form, never the $HOME shorthand. + assert.equal(prefix, `${resolvedTarget}/`); + assert.ok(!prefix.startsWith('$HOME'), 'opencode must not use $HOME shorthand'); + } + }, + ), + ); + }); + + test('property: non-global never collapses — always the resolved absolute form', () => { + fc.assert( + fc.property( + fc.record({ + home: fc.constantFrom(...HOMES), + segs: fc.array(SEG, { minLength: 1, maxLength: 3 }), + }), + ({ home, segs }) => { + const resolvedTarget = target(home, segs); + const prefix = conversion._computePathPrefix({ + isGlobal: false, + isOpencode: false, + isWindowsHost: false, + resolvedTarget, + homeDir: home, + }); + assert.equal(prefix, `${resolvedTarget}/`); + assert.ok(!prefix.startsWith('$HOME')); + }, + ), + ); + }); + + test('property: backslash paths project identically to their posix form (#1615)', () => { + fc.assert( + fc.property( + fc.record({ + home: fc.constantFrom(...HOMES), + segs: fc.array(SEG, { minLength: 1, maxLength: 3 }), + }), + ({ home, segs }) => { + const posixTarget = target(home, segs); + const backslashTarget = posixTarget.replace(/\//g, '\\'); + const backslashHome = home.replace(/\//g, '\\'); + const fromBackslash = conversion._computePathPrefix({ + isGlobal: true, + isOpencode: false, + isWindowsHost: false, + resolvedTarget: backslashTarget, + homeDir: backslashHome, + }); + const fromPosix = conversion._computePathPrefix({ + isGlobal: true, + isOpencode: false, + isWindowsHost: false, + resolvedTarget: posixTarget, + homeDir: home, + }); + assert.equal(fromBackslash, fromPosix); + }, + ), + ); + }); +}); + +describe('_applyRuntimeRewrites: path-rewrite idempotency (#1676 / ADR-1508)', () => { + test('property: f(f(content)) === f(content) across path-rewriting runtimes', () => { + fc.assert( + fc.property( + fc.record({ + runtime: fc.constantFrom(...PATH_REWRITE_RUNTIMES), + home: fc.constantFrom(...HOMES), + isGlobal: fc.boolean(), + segs: fc.array(SEG, { minLength: 1, maxLength: 4 }), + }), + ({ runtime, home, isGlobal, segs }) => { + // configDir under $HOME so global collapses to $HOME/./; + // non-global projects to an absolute form. Both are prefix shapes + // that contain no matchable ~/.claude or $HOME/.claude token, so a + // second rewrite pass is a no-op. + const dirName = getDirName(runtime).replace(/^\./, ''); + const configDir = target(home, [`.${dirName}`]); + const pathPrefix = conversion._computePathPrefix({ + isGlobal, + isOpencode: false, + isWindowsHost: false, + resolvedTarget: configDir, + homeDir: home, + }); + + // Seed content with every reference shape the engine rewrites, + // interleaved with inert prose so rewrites land mid-line. + const lines = segs.map((s) => + `See ~/.claude/skills/${s} or $HOME/.claude/agents/${s}; also ./.claude/${s}.`); + const content = lines.join('\n'); + + const once = conversion._applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal, undefined); + const twice = conversion._applyRuntimeRewrites(once, runtime, pathPrefix, isGlobal, undefined); + assert.equal(twice, once, 'second rewrite pass must be a no-op (idempotent)'); + // Sanity: the seed references were actually rewritten on the first pass. + assert.ok(!once.includes('~/.claude/'), 'first pass must eliminate ~/.claude/ refs'); + }, + ), + ); + }); +}); + }); +} diff --git a/tests/runtime-name-policy.test.cjs b/tests/runtime-name-policy.test.cjs index 43a9adacb..0a21e8e82 100644 --- a/tests/runtime-name-policy.test.cjs +++ b/tests/runtime-name-policy.test.cjs @@ -124,3 +124,118 @@ describe('runtime-name-policy getProjectInstructionFile (#1529)', () => { assert.strictEqual(getProjectInstructionFile('github-copilot'), '.github/copilot-instructions.md'); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-783-kilo-global-skills-base.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-783-kilo-global-skills-base (consolidation epic #1969 B3 #1972)", () => { +'use strict'; +// Regression guard for bug #783. +// +// getGlobalSkillsBase('kilo') was returning ~/.config/kilo/skills (the XDG +// config dir) instead of ~/.kilo/skills — where Kilo Code actually discovers +// global skills per its docs: +// https://kilo.ai/docs/customize/skills +// "Global skills are located in the `.kilo` directory within your Home +// directory: ~/.kilo/skills/" +// +// The fix adds a special case in getGlobalSkillsBase() that resolves kilo's +// skills dir from HOME (not from the XDG config dir). The config dir at +// ~/.config/kilo is still CORRECT for commands (command/) and must stay +// unchanged — this test verifies both roles are separate. + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const os = require('node:os'); + +const ROOT = path.join(__dirname, '..'); +const { + getGlobalConfigDir, + getGlobalSkillsBase, +} = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-homes.cjs')); + +// Helper: temporarily override env vars for a test, restoring them afterwards. +function withEnv(overrides, fn) { + const saved = {}; + for (const [key, value] of Object.entries(overrides)) { + saved[key] = process.env[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + try { + return fn(); + } finally { + for (const [key] of Object.entries(overrides)) { + if (saved[key] === undefined) delete process.env[key]; + else process.env[key] = saved[key]; + } + } +} + +// Clear all kilo-relevant env vars so tests are hermetic. +const kiloEnvClears = { + KILO_CONFIG_DIR: undefined, + XDG_CONFIG_HOME: undefined, +}; + +describe('bug #783: kilo global skills dir is ~/.kilo/skills, not ~/.config/kilo/skills', () => { + test('getGlobalSkillsBase("kilo") resolves to ~/.kilo/skills', () => { + withEnv(kiloEnvClears, () => { + assert.strictEqual( + getGlobalSkillsBase('kilo'), + path.join(os.homedir(), '.kilo', 'skills'), + ); + }); + }); + + test('getGlobalConfigDir("kilo") still resolves to ~/.config/kilo (config dir unchanged)', () => { + withEnv(kiloEnvClears, () => { + assert.strictEqual( + getGlobalConfigDir('kilo'), + path.join(os.homedir(), '.config', 'kilo'), + ); + }); + }); + + test('kilo skills dir and config dir are decoupled (not equal, not nested)', () => { + withEnv(kiloEnvClears, () => { + const skillsBase = getGlobalSkillsBase('kilo'); + const configDir = getGlobalConfigDir('kilo'); + + assert.notStrictEqual(skillsBase, configDir, 'skills dir must differ from config dir'); + assert.ok( + !skillsBase.startsWith(configDir + path.sep), + `skills dir (${skillsBase}) must not be nested under config dir (${configDir})`, + ); + assert.ok( + !configDir.startsWith(skillsBase + path.sep), + `config dir (${configDir}) must not be nested under skills dir (${skillsBase})`, + ); + }); + }); + + test('getGlobalSkillsBase("kilo") is NOT affected by KILO_CONFIG_DIR override', () => { + // Skills always live in ~/.kilo/skills regardless of XDG/config-dir overrides. + withEnv({ KILO_CONFIG_DIR: '/tmp/custom-kilo-config', XDG_CONFIG_HOME: undefined }, () => { + assert.strictEqual( + getGlobalSkillsBase('kilo'), + path.join(os.homedir(), '.kilo', 'skills'), + ); + }); + }); + + test('getGlobalSkillsBase("kilo") is NOT affected by XDG_CONFIG_HOME override', () => { + withEnv({ KILO_CONFIG_DIR: undefined, XDG_CONFIG_HOME: '/tmp/custom-xdg' }, () => { + assert.strictEqual( + getGlobalSkillsBase('kilo'), + path.join(os.homedir(), '.kilo', 'skills'), + ); + }); + }); +}); + }); +} diff --git a/tests/semver-compare.test.cjs b/tests/semver-compare.test.cjs index fb63d4076..3e8571782 100644 --- a/tests/semver-compare.test.cjs +++ b/tests/semver-compare.test.cjs @@ -177,3 +177,43 @@ describe('semverSatisfies (ADR-1244 engines.gsd range gate)', () => { sat(null, '>=1.6.0', false); // null version -> [0,0,0] -> not >= 1.6.0 }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-10-semver-policy-consolidation.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-10-semver-policy-consolidation (consolidation epic #1969 B3 #1972)", () => { +'use strict'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); + +const { + compareSemverCore, + isStableTripletSemver, +} = require('../gsd-core/bin/lib/semver-compare.cjs'); +const { isInstalledAheadOfLatest } = require('../hooks/gsd-statusline.js'); + +describe('bug #10: semver policy consolidation', () => { + test('shared comparator treats prerelease patch increment as newer than previous stable', () => { + assert.ok(compareSemverCore('1.2.1-beta.1', '1.2.0') > 0); + }); + + test('shared comparator ignores prerelease suffix for equal base versions', () => { + assert.equal(compareSemverCore('1.2.0-rc.1', '1.2.0'), 0); + }); + + test('stable-triplet validator excludes prerelease tags', () => { + assert.equal(isStableTripletSemver('1.2.0-rc.1'), false); + assert.equal(isStableTripletSemver('1.2.0'), true); + }); + + test('statusline dev-install detection uses shared comparator semantics', () => { + assert.equal(isInstalledAheadOfLatest('1.2.1-beta.1', '1.2.0'), true); + assert.equal(isInstalledAheadOfLatest('1.2.0-rc.1', '1.2.0'), false); + }); +}); + }); +} diff --git a/tests/shell-command-projection-dispatch.test.cjs b/tests/shell-command-projection-dispatch.test.cjs index e4ff7f613..dfdd7665f 100644 --- a/tests/shell-command-projection-dispatch.test.cjs +++ b/tests/shell-command-projection-dispatch.test.cjs @@ -261,3 +261,1174 @@ describe('platformEnsureDir', () => { assert.doesNotThrow(() => platformEnsureDir(dirPath)); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-1906-hook-relative-paths.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-1906-hook-relative-paths (consolidation epic #1969 B3 #1972)", () => { +/** + * Regression tests for bug #1906 + * + * Local installs must anchor hook command paths to $CLAUDE_PROJECT_DIR so + * hooks resolve correctly regardless of the shell's current working directory. + * + * The original bug: local install hook commands used bare relative paths like + * `node .claude/hooks/gsd-context-monitor.js`. Claude Code persists the bash + * tool's cwd between calls, so a single `cd subdir && …` early in a session + * permanently broke every hook for the rest of that session. + * + * The fix prefixes all local hook commands with "$CLAUDE_PROJECT_DIR"/ so + * path resolution is always anchored to the project root. + */ + +'use strict'; + +const { describe, test, before } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('path'); + +const projection = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'shell-command-projection.cjs')); +const { projectLocalHookPrefix, projectShellCommandText } = projection; + +describe('bug #1906: local hook commands use $CLAUDE_PROJECT_DIR', () => { + before(() => { + assert.equal(typeof projectLocalHookPrefix, 'function'); + assert.equal(typeof projectShellCommandText, 'function'); + }); + + test('non-Gemini runtimes get $CLAUDE_PROJECT_DIR anchored local prefix', () => { + const prefix = projectLocalHookPrefix({ runtime: 'claude', dirName: '.claude' }); + assert.equal(prefix, '"$CLAUDE_PROJECT_DIR"/.claude'); + }); + + test('local command projection for non-Gemini keeps $CLAUDE_PROJECT_DIR anchor', () => { + const prefix = projectLocalHookPrefix({ runtime: 'claude', dirName: '.claude' }); + const command = projectShellCommandText({ + runnerToken: '"/usr/local/bin/node"', + argTokens: [`${prefix}/hooks/gsd-context-monitor.js`], + runtime: 'claude', + platform: 'linux', + }); + assert.equal( + command, + '"/usr/local/bin/node" "$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-context-monitor.js', + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2557-gemini-local-hook-paths.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2557-gemini-local-hook-paths (consolidation epic #1969 B3 #1972)", () => { +'use strict'; + +/** + * Bug #2557: Gemini CLI local hook commands must NOT use $CLAUDE_PROJECT_DIR. + * + * $CLAUDE_PROJECT_DIR is a Claude Code-specific env variable. Gemini CLI does + * not set it. On Windows, Gemini's own variable-substitution + path-join logic + * produced a doubled path like `D:\Projects\GSD\'D:\Projects\GSD'`, causing + * every local project hook to fail at SessionStart. + * + * Fix: localPrefix is now runtime-conditional. Gemini/Antigravity use bare + * dirName (relative path) since they always run project hooks with the project + * dir as cwd. Claude Code and others still use "$CLAUDE_PROJECT_DIR"/ (#1906). + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const projection = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'shell-command-projection.cjs')); +const { projectLocalHookPrefix, projectShellCommandText } = projection; + +describe('bug #2557: Gemini/Antigravity local hooks use relative paths (not $CLAUDE_PROJECT_DIR)', () => { + test('Gemini local prefix is bare dirName', () => { + assert.equal(projectLocalHookPrefix({ runtime: 'gemini', dirName: '.gemini' }), '.gemini'); + }); + + test('Antigravity local prefix is bare dirName', () => { + assert.equal(projectLocalHookPrefix({ runtime: 'antigravity', dirName: '.agents' }), '.agents'); + }); + + test('non-Gemini local prefix remains $CLAUDE_PROJECT_DIR anchored', () => { + assert.equal( + projectLocalHookPrefix({ runtime: 'claude', dirName: '.claude' }), + '"$CLAUDE_PROJECT_DIR"/.claude', + ); + }); + + test('Gemini local command projection does not contain "$CLAUDE_PROJECT_DIR"', () => { + const prefix = projectLocalHookPrefix({ runtime: 'gemini', dirName: '.gemini' }); + const command = projectShellCommandText({ + runnerToken: '"/usr/local/bin/node"', + argTokens: [`${prefix}/hooks/gsd-check-update.js`], + runtime: 'gemini', + platform: 'linux', + }); + assert.ok( + !command.includes('$CLAUDE_PROJECT_DIR'), + 'Gemini local command must not include $CLAUDE_PROJECT_DIR', + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3413-shell-command-projection.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3413-shell-command-projection (consolidation epic #1969 B3 #1972)", () => { +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const projection = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'shell-command-projection.cjs')); +const install = require(path.join(__dirname, '..', 'bin', 'install.js')); + +const { + hookCommandNeedsPowerShellCallOperator, + formatHookCommandForRuntime, + isManagedHookBasename, + isManagedHookCommand, + projectLocalHookPrefix, + projectLegacySettingsHookCommand, + projectPortableHookBaseDir, +} = projection; +const { buildHookCommand, rewriteLegacyManagedNodeHookCommands } = install; + +describe('bug #3413: Shell Command Projection Module uses runtime-aware hook policy', () => { + test('Gemini on Windows requires PowerShell call operator', () => { + assert.equal( + hookCommandNeedsPowerShellCallOperator({ platform: 'win32', runtime: 'gemini' }), + true, + ); + assert.equal( + formatHookCommandForRuntime('"C:/node.exe" "C:/hook.js"', { platform: 'win32', runtime: 'gemini' }), + '& "C:/node.exe" "C:/hook.js"', + ); + }); + + test('Claude on Windows stays shell-neutral', () => { + assert.equal( + hookCommandNeedsPowerShellCallOperator({ platform: 'win32', runtime: 'claude' }), + false, + ); + assert.equal( + formatHookCommandForRuntime('"C:/node.exe" "C:/hook.js"', { platform: 'win32', runtime: 'claude' }), + '"C:/node.exe" "C:/hook.js"', + ); + }); + + test('runtime omitted stays conservative (no PowerShell prefix)', () => { + assert.equal( + formatHookCommandForRuntime('"C:/node.exe" "C:/hook.js"', { platform: 'win32' }), + '"C:/node.exe" "C:/hook.js"', + ); + }); +}); + +describe('bug #3413: installer hook surfaces consume runtime-aware projection', () => { + test('buildHookCommand emits shell-neutral Claude hook command on Windows', () => { + const cmd = buildHookCommand('C:/Users/me/.claude', 'gsd-check-update.js', { + platform: 'win32', + runtime: 'claude', + }); + assert.equal(cmd.startsWith('& '), false, `Claude hook command must not use PowerShell prefix: ${cmd}`); + }); + + test('rewriteLegacyManagedNodeHookCommands removes stale PowerShell prefix for Claude on Windows', () => { + const settings = { + hooks: { + SessionStart: [{ + hooks: [{ type: 'command', command: '& "/usr/local/bin/node" "C:/Users/me/.claude/hooks/gsd-check-update.js"' }], + }], + }, + }; + const changed = rewriteLegacyManagedNodeHookCommands(settings, '"/usr/local/bin/node"', { + platform: 'win32', + runtime: 'claude', + }); + assert.equal(changed, true); + assert.equal( + settings.hooks.SessionStart[0].hooks[0].command, + '"/usr/local/bin/node" "C:/Users/me/.claude/hooks/gsd-check-update.js"', + ); + }); +}); + +describe('bug #3439: shell projection module owns managed-hook policy and legacy rewrite projection', () => { + test('isManagedHookBasename is surface-aware', () => { + assert.equal(isManagedHookBasename('/x/hooks/gsd-check-update.js', { surface: 'settings-json' }), true); + assert.equal(isManagedHookBasename('/x/hooks/gsd-statusline.js', { surface: 'settings-json' }), true); + assert.equal(isManagedHookBasename('/x/hooks/gsd-statusline.js', { surface: 'codex-toml' }), false); + assert.equal(isManagedHookBasename('/x/hooks/custom-hook.js', { surface: 'settings-json' }), false); + }); + + test('projectLegacySettingsHookCommand preserves non-Windows script token shape', () => { + const command = projectLegacySettingsHookCommand({ + absoluteRunner: '"/usr/local/bin/node"', + scriptPath: '/x/hooks/gsd-statusline.js', + scriptToken: "'/x/hooks/gsd-statusline.js'", + platform: 'linux', + runtime: 'claude', + }); + assert.equal(command, `"/usr/local/bin/node" '/x/hooks/gsd-statusline.js'`); + }); + + test('projectLegacySettingsHookCommand normalizes Windows managed paths and runtime wrapper policy', () => { + const command = projectLegacySettingsHookCommand({ + absoluteRunner: '"C:/nvm4w/nodejs/node.exe"', + scriptPath: 'C:\\Users\\me\\.gemini\\hooks\\gsd-prompt-guard.js', + scriptToken: "'C:\\Users\\me\\.gemini\\hooks\\gsd-prompt-guard.js'", + platform: 'win32', + runtime: 'gemini', + }); + assert.equal(command, '& "C:/nvm4w/nodejs/node.exe" "C:/Users/me/.gemini/hooks/gsd-prompt-guard.js"'); + }); + + test('projectLocalHookPrefix centralizes runtime-specific project-dir interpolation policy', () => { + assert.equal(projectLocalHookPrefix({ runtime: 'gemini', dirName: '.gemini' }), '.gemini'); + assert.equal(projectLocalHookPrefix({ runtime: 'antigravity', dirName: '.agents' }), '.agents'); + assert.equal( + projectLocalHookPrefix({ runtime: 'claude', dirName: '.claude' }), + '"$CLAUDE_PROJECT_DIR"/.claude', + ); + }); + + test('projectPortableHookBaseDir centralizes $HOME interpolation policy', () => { + assert.equal( + projectPortableHookBaseDir({ + configDir: '/Users/me/.claude', + homeDir: '/Users/me', + }), + '$HOME/.claude', + ); + assert.equal( + projectPortableHookBaseDir({ + configDir: 'C:\\Users\\me\\.claude', + homeDir: 'C:\\Users\\me', + }), + '$HOME/.claude', + ); + assert.equal( + projectPortableHookBaseDir({ + configDir: '/opt/custom/.claude', + homeDir: '/Users/me', + }), + '/opt/custom/.claude', + ); + }); + + test('isManagedHookCommand classifies managed settings hooks and leaves user commands untouched', () => { + assert.equal( + isManagedHookCommand('"/usr/local/bin/node" "/Users/me/.claude/hooks/gsd-statusline.js"', { + surface: 'settings-json', + }), + true, + ); + assert.equal( + isManagedHookCommand('"C:/Program Files/Git/bin/bash.exe" "C:/Users/me/.claude/hooks/gsd-session-state.sh"', { + surface: 'settings-json', + }), + true, + ); + assert.equal( + isManagedHookCommand('bash /Users/me/.claude/hooks/custom-lint.sh', { + surface: 'settings-json', + }), + false, + ); + }); + + test('isManagedHookCommand supports codex surfaces and optional legacy alias matching', () => { + const command = '"/usr/local/bin/node" "/Users/me/.codex/hooks/gsd-check-update.js"'; + assert.equal( + isManagedHookCommand(command, { + surface: 'codex-toml', + }), + true, + ); + assert.equal( + isManagedHookCommand('"/usr/local/bin/node" "/Users/me/.codex/hooks/gsd-update-check.js"', { + surface: 'codex-toml', + }), + false, + ); + assert.equal( + isManagedHookCommand('"/usr/local/bin/node" "/Users/me/.codex/hooks/gsd-update-check.js"', { + surface: 'codex-toml', + includeLegacyAliases: true, + }), + true, + ); + }); +}); + +describe('#1693 regression: Windows legacy-node rewrite must not double-quote a "$CLAUDE_PROJECT_DIR"-anchored local hook path', () => { + const winRunner = '"C:/Program Files/nodejs/node.exe"'; + + // WHY: a local-install hook path already carries a `"$CLAUDE_PROJECT_DIR"` + // anchored prefix (only the variable quoted, rest bare). On Windows the legacy + // rewrite previously JSON.stringify'd the whole token, yielding + // `"\"$CLAUDE_PROJECT_DIR\"/..."`. node then received an argument starting with + // a literal `"`, treated it as relative, and died with MODULE_NOT_FOUND — + // breaking every node managed hook at once (self-locking deadlock). + test('projectLegacySettingsHookCommand emits the anchored path verbatim, not re-quoted', () => { + const anchored = '"$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-context-monitor.js'; + const command = projectLegacySettingsHookCommand({ + absoluteRunner: winRunner, + scriptPath: anchored, + scriptToken: anchored, + platform: 'win32', + runtime: 'claude', + }); + assert.equal( + command, + '"C:/Program Files/nodejs/node.exe" "$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-context-monitor.js', + ); + assert.ok(!command.includes('\\"'), 'must not contain escaped double-quotes'); + }); + + // WHY: the fix must be surgical — a BARE absolute Windows path (no anchored + // prefix) can contain spaces ("Program Files") and still REQUIRES quoting. + test('projectLegacySettingsHookCommand still quotes a bare absolute Windows path', () => { + const abs = 'C:/Program Files App/.claude/hooks/gsd-context-monitor.js'; + const command = projectLegacySettingsHookCommand({ + absoluteRunner: winRunner, + scriptPath: abs, + scriptToken: JSON.stringify(abs), + platform: 'win32', + runtime: 'claude', + }); + assert.equal( + command, + '"C:/Program Files/nodejs/node.exe" "C:/Program Files App/.claude/hooks/gsd-context-monitor.js"', + ); + }); + + // WHY: the anchored short-circuit is scoped to win32. On POSIX the rewrite + // already preserved the caller's original `scriptToken` and never had the + // double-quote bug, so that behavior must be left byte-identical. scriptPath + // is anchored but scriptToken is a DISTINCT single-quoted value: if the + // win32 gate were removed, the anchored short-circuit would emit scriptPath + // and this assertion would fail — that is what pins the gate. + test('projectLegacySettingsHookCommand preserves the original scriptToken for anchored paths on POSIX', () => { + const command = projectLegacySettingsHookCommand({ + absoluteRunner: '"/usr/local/bin/node"', + scriptPath: '"$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-statusline.js', + scriptToken: "'/x/hooks/gsd-statusline.js'", + platform: 'linux', + runtime: 'claude', + }); + assert.equal(command, `"/usr/local/bin/node" '/x/hooks/gsd-statusline.js'`); + }); + + // WHY: end-to-end through the installer rewrite — the actual #2979 path that + // ran during the user's 1.5.0 -> 1.6.0 local update. Managed node hooks get + // the absolute runner + clean anchored path; a non-node-prefixed managed .sh + // hook (already correct) is left untouched. + test('rewriteLegacyManagedNodeHookCommands produces clean anchored node commands on Windows', () => { + const settings = { + hooks: { + PostToolUse: [ + { + hooks: [ + { command: 'node "$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-context-monitor.js' }, + ], + }, + ], + }, + }; + const changed = rewriteLegacyManagedNodeHookCommands(settings, winRunner, { + platform: 'win32', + runtime: 'claude', + }); + assert.equal(changed, true); + const rewritten = settings.hooks.PostToolUse[0].hooks[0].command; + assert.equal( + rewritten, + '"C:/Program Files/nodejs/node.exe" "$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-context-monitor.js', + ); + assert.ok(!rewritten.includes('\\"'), 'rewritten command must not contain escaped double-quotes'); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3441-path-action-projection.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3441-path-action-projection (consolidation epic #1969 B3 #1972)", () => { +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const fs = require('node:fs'); +const os = require('node:os'); + +const projection = require(path.join( + __dirname, + '..', + 'gsd-core', + 'bin', + 'lib', + 'shell-command-projection.cjs', +)); +const install = require(path.join(__dirname, '..', 'bin', 'install.js')); +const { withIsolatedProcessState, cleanup } = require('./helpers.cjs'); + +function createTempHome() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-home-3441-')); +} + + +describe('bug #3441: PATH guidance is projected from typed shell action IR', () => { + test('projection module exports PATH action projection helper', () => { + assert.equal(typeof projection.projectPathActionProjection, 'function'); + }); + + // (formatSdkPathDiagnostic removed with the gsd-sdk shim, #191 — the PATH + // action projection it wrapped is still covered by the tests below.) + + test('persistent PATH export guidance is projected via the same seam', () => { + const posix = projection.projectPathActionProjection({ + mode: 'persist', + targetDir: '/tmp/with quote', + platform: 'linux', + }); + assert.ok(Array.isArray(posix.shellActions)); + assert.equal(posix.shellActions.length, 3); + assert.equal(posix.shellActions[0].label, 'zsh'); + assert.equal(posix.shellActions[1].label, 'bash'); + assert.equal(posix.shellActions[2].label, 'fish'); + assert.ok(posix.shellActions[0].command.includes('~/.zshrc')); + assert.ok(posix.shellActions[1].command.includes('~/.bashrc')); + // #323: fish gets a fish-native fish_add_path suggestion, not `export`. + assert.ok(posix.shellActions[2].command.startsWith('fish_add_path ')); + assert.ok(!posix.shellActions[2].command.includes('export')); + }); + + // #323 (ported from the closed #721): the fish suggestion is POSIX-only. + // On win32 the persist branch projects PowerShell / cmd.exe / Git Bash — + // no fish action — locking the POSIX-only contract. + test('no fish action is projected on win32', () => { + const win = projection.projectPathActionProjection({ + mode: 'persist', + targetDir: 'C:\\Users\\me\\AppData\\npm', + platform: 'win32', + }); + assert.ok(Array.isArray(win.shellActions)); + assert.equal( + win.shellActions.some((a) => a.shell === 'fish' || a.label === 'fish'), + false, + 'win32 persist projection must not include a fish action', + ); + assert.deepEqual( + win.shellActions.map((a) => a.label), + ['PowerShell', 'cmd.exe', 'Git Bash'], + ); + }); + + test('POSIX repair mode escapes double-quoted shell metacharacters', () => { + const projected = projection.projectPathActionProjection({ + mode: 'repair', + targetDir: '/tmp/qa\\"$HOME`tick', + platform: 'linux', + }); + assert.equal(projected.shellActions.length, 1); + assert.equal( + projected.shellActions[0].command, + 'export PATH="/tmp/qa\\\\\\"\\$HOME\\`tick:$PATH"', + ); + }); + + test('POSIX persist mode escapes single quotes for rc-file echo commands', () => { + const projected = projection.projectPathActionProjection({ + mode: 'persist', + targetDir: "/tmp/O'Neil/bin", + platform: 'linux', + }); + assert.equal(projected.shellActions[0].command.includes("/tmp/O'\\''Neil/bin"), true); + assert.equal(projected.shellActions[1].command.includes("/tmp/O'\\''Neil/bin"), true); + // #323: fish entry single-quotes the dir with the same POSIX literal + // escaping (`'\''` is also a valid escaped quote in fish unquoted context). + assert.equal(projected.shellActions[2].command, "fish_add_path '/tmp/O'\\''Neil/bin'"); + }); + + test('maybeSuggestPathExport renders commands projected by path-action seam', () => { + const home = createTempHome(); + try { + withIsolatedProcessState(() => { + const globalBin = path.join(home, '.npm-global', 'bin'); + fs.mkdirSync(globalBin, { recursive: true }); + fs.writeFileSync(path.join(home, '.zshrc'), 'export PATH="$HOME/.cargo/bin:$PATH"\n'); + process.env.PATH = ''; + + const expected = projection.projectPathActionProjection({ + mode: 'persist', + targetDir: globalBin, + platform: process.platform, + }); + + const logs = []; + const originalLog = console.log; + console.log = (...args) => logs.push(args.join(' ')); + try { + install.maybeSuggestPathExport(globalBin, home); + } finally { + console.log = originalLog; + } + + const joined = logs.join('\n'); + for (const action of expected.shellActions) { + assert.ok( + joined.includes(action.command), + `expected installer output to include projected command: ${action.command}\nOutput:\n${joined}`, + ); + } + }); + } finally { + cleanup(home); + } + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-580-local-sh-hook-bash-wrapper.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-580-local-sh-hook-bash-wrapper (consolidation epic #1969 B3 #1972)", () => { +'use strict'; + +/** + * Regression test for bug #580. + * + * LOCAL install under Claude Code on Windows: managed `.sh` hooks were emitted + * wrapped with an absolute `bash.exe` path via the `localShellCmd` arrow. + * Since Claude Code runs hook command strings INSIDE Git Bash, bash tries to + * exec bash → "cannot execute binary file". + * + * The GLOBAL path (`buildHookCommand`) already guarded win32+claude+.sh (#166). + * The LOCAL path (`localShellCmd`) did not. Fix: add `buildLocalShellHookCommand` + * and `shellHookOmitsBashRunner` to shell-command-projection.cjs, and use + * `buildLocalShellHookCommand` from install.js local-install path. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const projection = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'shell-command-projection.cjs')); +const { buildLocalShellHookCommand, shellHookOmitsBashRunner, projectLocalHookPrefix } = projection; + +describe('bug #580: local .sh hooks on Claude/Windows must NOT wrap with bash.exe', () => { + test('local .sh hook on Claude/Windows omits the bash.exe wrapper (#580)', () => { + const localPrefix = projectLocalHookPrefix({ runtime: 'claude', dirName: '.claude' }); + const result = buildLocalShellHookCommand({ + localPrefix, + hookFile: 'gsd-session-state.sh', + bashRunner: '"C:/Program Files/Git/bin/bash.exe"', + runtime: 'claude', + platform: 'win32', + }); + assert.equal(result, '"$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-session-state.sh'); + assert.ok(!result.includes('bash.exe'), `result must not contain bash.exe, got: ${result}`); + }); + + test('local .sh hook on Claude/Windows still emits script path when bash.exe is unresolved', () => { + const localPrefix = projectLocalHookPrefix({ runtime: 'claude', dirName: '.claude' }); + const result = buildLocalShellHookCommand({ + localPrefix, + hookFile: 'gsd-session-state.sh', + bashRunner: null, + runtime: 'claude', + platform: 'win32', + }); + assert.equal(result, '"$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-session-state.sh'); + }); + + test('local .sh hook on POSIX keeps the bash runner', () => { + const localPrefix = projectLocalHookPrefix({ runtime: 'claude', dirName: '.claude' }); + const result = buildLocalShellHookCommand({ + localPrefix, + hookFile: 'gsd-session-state.sh', + bashRunner: 'bash', + runtime: 'claude', + platform: 'linux', + }); + assert.equal(result, 'bash "$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-session-state.sh'); + }); + + test('local .sh hook on Windows non-Claude runtime keeps the bash runner', () => { + const localPrefix = projectLocalHookPrefix({ runtime: 'codex', dirName: '.claude' }); + const result = buildLocalShellHookCommand({ + localPrefix, + hookFile: 'gsd-session-state.sh', + bashRunner: '"C:/Program Files/Git/bin/bash.exe"', + runtime: 'codex', + platform: 'win32', + }); + assert.ok(result.includes('bash.exe'), `result must contain bash.exe, got: ${result}`); + assert.ok(result.startsWith('"C:/Program Files/Git/bin/bash.exe"'), `result must start with bash.exe token, got: ${result}`); + }); + + test('all four managed local .sh hooks drop the wrapper on Claude/Windows', () => { + const localPrefix = projectLocalHookPrefix({ runtime: 'claude', dirName: '.claude' }); + const hooks = [ + 'gsd-session-state.sh', + 'gsd-validate-commit.sh', + 'gsd-graphify-update.sh', + 'gsd-phase-boundary.sh', + ]; + for (const f of hooks) { + const result = buildLocalShellHookCommand({ + localPrefix, + hookFile: f, + bashRunner: '"C:/Program Files/Git/bin/bash.exe"', + runtime: 'claude', + platform: 'win32', + }); + assert.equal( + result, + `"$CLAUDE_PROJECT_DIR"/.claude/hooks/${f}`, + `expected script-only path for ${f}, got: ${result}`, + ); + assert.ok(!result.includes('bash.exe'), `result for ${f} must not contain bash.exe, got: ${result}`); + } + }); + + test('shellHookOmitsBashRunner truth table', () => { + // true only for win32 + claude + isShellHook:true + assert.equal(shellHookOmitsBashRunner({ platform: 'win32', runtime: 'claude', isShellHook: true }), true); + + // false for win32 + claude + isShellHook:false + assert.equal(shellHookOmitsBashRunner({ platform: 'win32', runtime: 'claude', isShellHook: false }), false); + + // false for win32 + codex + isShellHook:true + assert.equal(shellHookOmitsBashRunner({ platform: 'win32', runtime: 'codex', isShellHook: true }), false); + + // false for linux + claude + isShellHook:true + assert.equal(shellHookOmitsBashRunner({ platform: 'linux', runtime: 'claude', isShellHook: true }), false); + + // false for default args (no win32, no claude) + assert.equal(shellHookOmitsBashRunner(), false); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/feat-3595-fs-fault-injection-atomic-write.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:feat-3595-fs-fault-injection-atomic-write (consolidation epic #1969 B3 #1972)", () => { +/** + * Filesystem fault-injection coverage for the canonical atomic-write + * seam (#3595). + * + * `platformWriteSync` in `gsd-core/bin/lib/shell-command-projection.cjs` + * is the shared seam every config/state/generated-artifact writer in + * the CJS layer routes through. Its contract: + * + * 1. mkdirSync(dirname, { recursive: true }) — ensure parent exists. + * 2. writeFileSync(, content) — write to a sibling tmpfile + * named `.tmp.`. + * 3. renameSync(, filePath) — atomic publish. + * 4. On any error in steps 2-3: unlinkSync() (best-effort) + * then writeFileSync(filePath, content) directly as a fallback. + * + * Per CONTRIBUTING.md §"QA Matrix Requirements / Filesystem writes and + * installers" the tests below use `mock.method()` against the real `fs` + * seam to drive each fault mode, restore mocks with `t.after()`, and + * assert on observable post-conditions (file existence, content, + * presence/absence of orphan tmp files, propagated error code) — not + * on prose. + * + * Pre-existing behavior gaps surfaced and PINNED (not fixed in this + * PR — #3595 is test coverage, fixes belong in separate issues): + * + * - The "fall back to direct write" branch silently swallows the + * ORIGINAL error from the tmp+rename path. If the fallback ALSO + * fails, the operator only sees the fallback's error — not the + * original cause. Tests document this. + * - On EACCES against the parent directory mkdir, the error + * escapes (no try/catch around mkdirSync). Pinned. + */ + +'use strict'; + +const { test, mock } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { + platformWriteSync, + platformEnsureDir, +} = require('../gsd-core/bin/lib/shell-command-projection.cjs'); + +/** + * Create a fresh real-fs scratch dir per test so no two faults share + * state. Returns the directory; caller must clean up. + */ +const { createTempDir, cleanup } = require('./helpers.cjs'); +const mkScratch = (name) => createTempDir(`fs-fault-${name}-`); + +/** + * Enumerate orphan tmp files left behind by platformWriteSync. The + * tmp shape is `.tmp.`; we match that pattern strictly + * so a test that happens to write a real `*.tmp.123` doesn't get a + * false positive. + */ +function orphanTmpFiles(dir) { + if (!fs.existsSync(dir)) return []; + return fs.readdirSync(dir).filter((n) => /\.tmp\.\d+$/.test(n)); +} + +// ─── Happy path baseline ──────────────────────────────────────────────────── + +test('platformWriteSync happy path writes content atomically (baseline for fault tests)', (t) => { + const dir = mkScratch('happy'); + t.after(() => cleanup(dir)); + const file = path.join(dir, 'config.json'); + platformWriteSync(file, '{"k":"v"}\n'); + assert.equal(fs.readFileSync(file, 'utf-8'), '{"k":"v"}\n'); + assert.deepEqual(orphanTmpFiles(dir), [], 'happy path must leave no orphan tmp file'); +}); + +// ─── Rename failure → falls back to direct write ──────────────────────────── + +test('platformWriteSync recovers when renameSync fails (EXDEV cross-device fallback)', (t) => { + const dir = mkScratch('exdev'); + t.after(() => cleanup(dir)); + const file = path.join(dir, 'config.json'); + + // Simulate rename failing once (e.g. cross-device move on a CI runner + // with overlayfs). The fallback path must write the content directly. + let renameCalls = 0; + const renameMock = mock.method(fs, 'renameSync', (_src, _dest) => { + renameCalls++; + const err = new Error('EXDEV: cross-device link not permitted'); + err.code = 'EXDEV'; + throw err; + }); + t.after(() => renameMock.mock.restore()); + + platformWriteSync(file, 'fallback content\n'); + + // File exists and has the new content — fallback wrote it directly. + assert.equal(fs.readFileSync(file, 'utf-8'), 'fallback content\n'); + assert.equal(renameCalls, 1, 'renameSync was called exactly once before falling back'); + // The tmp file was unlinked by the fallback path's best-effort cleanup. + assert.deepEqual(orphanTmpFiles(dir), [], 'tmp file must be cleaned up after rename failure'); +}); + +// ─── #1540: transient Windows lock (EPERM/EBUSY/EACCES) is RETRIED, never +// fallen back to a non-atomic truncating write ─────────────────── + +test('platformWriteSync retries a transient EPERM rename and publishes atomically (#1540)', (t) => { + const dir = mkScratch('eperm-transient'); + t.after(() => cleanup(dir)); + const file = path.join(dir, 'STATE.md'); + + // A reader briefly holds the target open → rename throws EPERM once, then clears. + let renameCalls = 0; + const originalRename = fs.renameSync; + const renameMock = mock.method(fs, 'renameSync', (src, dest) => { + renameCalls++; + if (renameCalls === 1) { + const err = new Error('EPERM: a reader holds the target open'); + err.code = 'EPERM'; + throw err; + } + return originalRename.call(fs, src, dest); + }); + t.after(() => renameMock.mock.restore()); + + platformWriteSync(file, 'published\n'); + + assert.equal(renameCalls, 2, 'rename retried after a transient EPERM (not a single-shot non-atomic fallback)'); + assert.equal(fs.statSync(file).isFile(), true); + assert.ok(fs.statSync(file).size > 0, 'target published, not truncated'); + assert.deepEqual(orphanTmpFiles(dir), [], 'atomic publish leaves no tmp orphan'); +}); + +test('platformWriteSync surfaces a PERSISTENT EPERM instead of truncating a concurrent reader (#1540)', (t) => { + const dir = mkScratch('eperm-persistent'); + t.after(() => cleanup(dir)); + const file = path.join(dir, 'STATE.md'); + // A reader is mid-read on `file` with known content. The old blanket fallback + // would non-atomically writeFileSync over it — truncating the reader. The fix + // must surface the error and leave the existing file byte-for-byte intact. + fs.writeFileSync(file, 'OLD CONTENT A READER IS MID-READ ON\n'); + const sizeBefore = fs.statSync(file).size; + + let renameCalls = 0; + const renameMock = mock.method(fs, 'renameSync', () => { + renameCalls++; + const err = new Error('EPERM: reader holds the target open'); + err.code = 'EPERM'; + throw err; + }); + t.after(() => renameMock.mock.restore()); + + let caught; + try { + platformWriteSync(file, 'NEW CONTENT\n'); + } catch (err) { + caught = err; + } + + assert.ok(caught, 'a persistent rename lock must surface as an error, not a silent truncating write'); + assert.equal(caught.code, 'EPERM'); + assert.equal(renameCalls, 3, 'rename retried up to the bounded limit before surfacing'); + // Negative proof: the concurrent reader's file was NOT truncated/overwritten. + assert.equal(fs.statSync(file).size, sizeBefore, 'target left intact — no non-atomic write happened'); + assert.equal(fs.readFileSync(file, 'utf-8'), 'OLD CONTENT A READER IS MID-READ ON\n'); + assert.deepEqual(orphanTmpFiles(dir), [], 'tmp cleaned up after surfacing the error'); +}); + +// ─── Tmp write failure → falls back to direct write ───────────────────────── + +test('platformWriteSync falls back when initial tmp writeFileSync fails (ENOSPC)', (t) => { + const dir = mkScratch('enospc'); + t.after(() => cleanup(dir)); + const file = path.join(dir, 'config.json'); + + // Make the FIRST writeFileSync (to .tmp.) fail with ENOSPC. The + // SECOND writeFileSync (the fallback, direct to filePath) succeeds. + let writeCalls = 0; + const realWrite = fs.writeFileSync; + const writeMock = mock.method(fs, 'writeFileSync', function (target, data, opts) { + writeCalls++; + if (writeCalls === 1) { + // First call is to the tmp path. + assert.match(String(target), /\.tmp\.\d+$/, 'first write must be to the tmp path'); + const err = new Error('ENOSPC: no space left on device'); + err.code = 'ENOSPC'; + throw err; + } + // Second call is the fallback to the real target. + assert.equal(target, file, 'fallback write must target the original file path'); + return realWrite.call(fs, target, data, opts); + }); + t.after(() => writeMock.mock.restore()); + + platformWriteSync(file, 'recovered\n'); + + assert.equal(writeCalls, 2, 'expected exactly 2 writeFileSync calls (tmp fail + fallback)'); + assert.equal(fs.readFileSync(file, 'utf-8'), 'recovered\n'); + // The fallback path tries unlinkSync on the tmp; the tmp never + // existed (its write failed), so unlink throws ENOENT and is + // swallowed by the inner catch. Either way: no orphan. + assert.deepEqual(orphanTmpFiles(dir), []); +}); + +// ─── Both attempts fail → error propagates (pinned current behavior) ──────── + +test('platformWriteSync propagates the FALLBACK error when both tmp and fallback writes fail', (t) => { + const dir = mkScratch('double-fail'); + t.after(() => cleanup(dir)); + const file = path.join(dir, 'config.json'); + + let writeCalls = 0; + const writeMock = mock.method(fs, 'writeFileSync', function () { + writeCalls++; + const err = new Error( + writeCalls === 1 + ? 'ENOSPC: original failure on tmp write' + : 'EACCES: permission denied on fallback write', + ); + err.code = writeCalls === 1 ? 'ENOSPC' : 'EACCES'; + throw err; + }); + t.after(() => writeMock.mock.restore()); + + // The current implementation does NOT chain the original cause; the + // fallback's error is what surfaces. This test pins that behavior so a + // future "preserve original error in .cause" fix is a visible change + // (open follow-up). + let caught; + try { + platformWriteSync(file, 'wont-write\n'); + } catch (err) { + caught = err; + } + assert.ok(caught, 'double-failure must throw'); + assert.equal(caught.code, 'EACCES', 'currently the fallback error wins (open: should chain via .cause)'); + // The original file was never created. + assert.equal(fs.existsSync(file), false); + // No orphan tmp left because the tmp write failed before any file was created. + assert.deepEqual(orphanTmpFiles(dir), []); +}); + +// ─── mkdirSync failure → escapes immediately (no try/catch upstream) ──────── + +test('platformWriteSync propagates mkdirSync failure unchanged (no swallowed parent-dir errors)', (t) => { + const dir = mkScratch('mkdir-fail'); + t.after(() => cleanup(dir)); + const file = path.join(dir, 'deep', 'nested', 'config.json'); + + const mkdirMock = mock.method(fs, 'mkdirSync', () => { + const err = new Error('EACCES: permission denied creating directory'); + err.code = 'EACCES'; + throw err; + }); + t.after(() => mkdirMock.mock.restore()); + + let caught; + try { + platformWriteSync(file, 'never-reached\n'); + } catch (err) { + caught = err; + } + assert.ok(caught, 'mkdir failure must propagate'); + assert.equal(caught.code, 'EACCES', 'mkdir error code must be preserved'); + // No partial write happened. + assert.equal(fs.existsSync(file), false); +}); + +// ─── Target path is a directory (writing OVER a dir is undefined) ────────── + +test('platformWriteSync against a target path that is an existing directory fails cleanly', (t) => { + const dir = mkScratch('target-is-dir'); + t.after(() => cleanup(dir)); + const file = path.join(dir, 'collides'); + // Pre-create the target AS a directory so the rename step would + // collide with a directory at the destination. + fs.mkdirSync(file); + + let caught; + try { + platformWriteSync(file, 'shouldnt-overwrite-dir\n'); + } catch (err) { + caught = err; + } + assert.ok(caught, 'writing over an existing directory must fail'); + // We don't pin the exact code (Node varies: EISDIR on rename, EPERM on + // some platforms). We pin: a real error code (string starting with E) + // surfaces, AND the directory was not replaced by a file. + assert.equal(typeof caught.code, 'string'); + assert.match(caught.code, /^E[A-Z]+$/, `expected an errno-style code, got ${caught.code}`); + assert.equal(fs.statSync(file).isDirectory(), true, 'pre-existing directory must remain a directory'); +}); + +// ─── Path with spaces, unicode, newline characters ───────────────────────── + +test('platformWriteSync handles paths with spaces, unicode, and newline characters', (t) => { + const dir = mkScratch('weird-path'); + t.after(() => cleanup(dir)); + const cases = [ + 'has spaces in name.json', + 'unicode-日本語-name.json', + ]; + if (process.platform !== 'win32') { + // Tab (0x09) and newline (0x0A) in filenames are POSIX-valid but + // Windows-illegal (NTFS forbids control characters 0x00–0x1F). Append + // both only on POSIX so cross-platform CI stays green. + cases.push('with\ttab.json'); + cases.push('with\nnewline.json'); + } + + for (const name of cases) { + const file = path.join(dir, name); + platformWriteSync(file, `payload for ${name}\n`); + assert.equal( + fs.readFileSync(file, 'utf-8'), + `payload for ${name}\n`, + `roundtrip failed for path "${JSON.stringify(name)}"`, + ); + } + assert.deepEqual(orphanTmpFiles(dir), [], 'no tmp orphans across the corpus'); +}); + +// ─── Orphan-tmp cleanup invariant ────────────────────────────────────────── + +test('platformWriteSync never leaks a tmp file after a successful happy-path write', (t) => { + const dir = mkScratch('no-orphan-happy'); + t.after(() => cleanup(dir)); + for (let i = 0; i < 25; i++) { + platformWriteSync(path.join(dir, `f-${i}.json`), `{"i":${i}}\n`); + } + // 25 real files, 0 tmp orphans. + const entries = fs.readdirSync(dir); + const tmpCount = entries.filter((n) => /\.tmp\.\d+$/.test(n)).length; + const realCount = entries.length - tmpCount; + assert.equal(realCount, 25); + assert.equal(tmpCount, 0); +}); + +// ─── platformEnsureDir is idempotent and chains errors ───────────────────── + +test('platformEnsureDir is idempotent on an existing directory', (t) => { + const dir = mkScratch('ensure-idem'); + t.after(() => cleanup(dir)); + const target = path.join(dir, 'a', 'b', 'c'); + // First call creates; subsequent calls must not throw EEXIST. + platformEnsureDir(target); + assert.equal(fs.statSync(target).isDirectory(), true); + // Repeat. + platformEnsureDir(target); + platformEnsureDir(target); + assert.equal(fs.statSync(target).isDirectory(), true); +}); + +test('platformEnsureDir propagates EACCES when parent dir is unwritable', (t) => { + const dir = mkScratch('ensure-fail'); + t.after(() => cleanup(dir)); + + const mkdirMock = mock.method(fs, 'mkdirSync', () => { + const err = new Error('EACCES: permission denied'); + err.code = 'EACCES'; + throw err; + }); + t.after(() => mkdirMock.mock.restore()); + + let caught; + try { + platformEnsureDir(path.join(dir, 'cannot-create')); + } catch (err) { + caught = err; + } + assert.ok(caught); + assert.equal(caught.code, 'EACCES'); +}); + +// ─── Symlink-following / escape behavior ──────────────────────────────────── + +test('platformWriteSync REPLACES a symlink with a regular file rather than following it (safe behavior)', (t) => { + // Security-relevant invariant: if the destination path is a symlink + // pointing somewhere the user did not intend the writer to touch + // (e.g. an attacker-planted symlink in `.planning/` pointing at + // `~/.ssh/authorized_keys`), the writer must NOT follow it and + // clobber the target. The rename-based atomic-write pattern delivers + // this property: `renameSync(tmp, symlinkPath)` replaces the symlink + // entry in the parent directory with the regular file at `tmp`. + // After the call: + // - `linkPath` is a regular file with the new content. + // - The original `realTarget` is UNTOUCHED. + // This test pins that property so a future refactor (e.g. switching + // to `fs.writeFileSync(linkPath, ...)` which follows symlinks) is a + // visible regression. + if (process.platform === 'win32') { + t.skip('symlinks on Win32 need admin'); + return; + } + + const dir = mkScratch('symlink-replace'); + t.after(() => cleanup(dir)); + + const realTarget = path.join(dir, 'real-target.json'); + fs.writeFileSync(realTarget, 'original — must not be touched\n'); + const linkPath = path.join(dir, 'link.json'); + fs.symlinkSync(realTarget, linkPath); + + platformWriteSync(linkPath, 'new content\n'); + + // The real target is UNTOUCHED — the safety property. + assert.equal( + fs.readFileSync(realTarget, 'utf-8'), + 'original — must not be touched\n', + 'symlink target must be preserved when writer writes "through" the link', + ); + // The link entry is now a regular file with the new content. + const stat = fs.lstatSync(linkPath); + assert.equal(stat.isSymbolicLink(), false, 'symlink entry replaced by a regular file (atomic rename semantics)'); + assert.equal(stat.isFile(), true); + assert.equal(fs.readFileSync(linkPath, 'utf-8'), 'new content\n'); +}); + +test('platformWriteSync against a broken symlink replaces it with the intended file', (t) => { + if (process.platform === 'win32') { + t.skip('symlinks on Win32 need admin'); + return; + } + const dir = mkScratch('symlink-broken'); + t.after(() => cleanup(dir)); + const link = path.join(dir, 'dangling.json'); + fs.symlinkSync(path.join(dir, 'does-not-exist'), link); + assert.equal(fs.lstatSync(link).isSymbolicLink(), true, 'pre-check: link is dangling'); + + platformWriteSync(link, 'real content\n'); + + assert.equal(fs.readFileSync(link, 'utf-8'), 'real content\n'); + assert.equal(fs.lstatSync(link).isSymbolicLink(), false, 'broken link replaced with regular file'); +}); + +// ─── Concurrent-write collision ──────────────────────────────────────────── + +test('platformWriteSync survives a concurrent collision on the same target path', (t) => { + // Two consecutive writes to the same path with DIFFERENT contents, + // separated by a setImmediate boundary so the second can interleave + // a mock-injected error mid-flight. The contract pinned here: after + // both writes complete, the file is parseable and contains ONE of the + // two contents — never a half-written corrupt blob. + // + // (True parallel writes from separate processes are out of scope — + // the writer is sync. This exercises mid-flight error recovery, which + // is the same concurrency hazard at a lower granularity.) + const dir = mkScratch('concurrent'); + t.after(() => cleanup(dir)); + const file = path.join(dir, 'race.json'); + + // First write completes normally. + platformWriteSync(file, '{"writer":"first"}\n'); + // Second write: inject a transient EBUSY on the first rename attempt, + // then succeed on the bounded retry (#1540). Capture the real renameSync + // BEFORE installing the mock so the retry attempt delegates to the real + // implementation. The previous form referenced a non-existent + // `fs.renameSync.wrapped` property — that branch would silently no-op + // instead of delegating. + let renameCalls = 0; + const originalRename = fs.renameSync; + const renameMock = mock.method(fs, 'renameSync', (src, dest) => { + renameCalls++; + if (renameCalls === 1) { + const err = new Error('EBUSY: file is locked'); + err.code = 'EBUSY'; + throw err; + } + return originalRename.call(fs, src, dest); + }); + t.after(() => renameMock.mock.restore()); + + platformWriteSync(file, '{"writer":"second"}\n'); + + // The bounded retry re-published the 'second' content atomically. + const final = fs.readFileSync(file, 'utf-8'); + // Must be valid JSON — never a half-merged corruption. + assert.doesNotThrow(() => JSON.parse(final), 'file must remain parseable after the contested write'); + // Must be the SECOND writer's content (it called platformWriteSync + // after the first, and the fallback completed). + assert.equal(final, '{"writer":"second"}\n'); + assert.deepEqual(orphanTmpFiles(dir), [], 'no tmp orphans after the contested write'); +}); + }); +} diff --git a/tests/skill-manifest.test.cjs b/tests/skill-manifest.test.cjs index c80461b61..7f07920cd 100644 --- a/tests/skill-manifest.test.cjs +++ b/tests/skill-manifest.test.cjs @@ -371,3 +371,350 @@ describe('skill-manifest', () => { cleanup(skillsDir); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-2792-namespace-skills.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-2792-namespace-skills (consolidation epic #1969 B3 #1972)", () => { +'use strict'; + +// allow-test-rule: source-text-is-the-product (see #2792) +// commands/gsd/*.md files ARE what the runtime loads — testing their +// frontmatter content tests the deployed system-prompt contract. + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { parseRequires } = require('./helpers/nested-layout.cjs'); + +const COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); + +const NAMESPACE_SKILLS = [ + { file: 'ns-workflow.md', name: 'gsd-workflow' }, + { file: 'ns-project.md', name: 'gsd-project' }, + { file: 'ns-review.md', name: 'gsd-quality' }, + { file: 'ns-context.md', name: 'gsd-context' }, + { file: 'ns-manage.md', name: 'gsd-manage' }, + { file: 'ns-ideate.md', name: 'gsd-ideate' }, +]; + +// Route targets named in any namespace body. The cross-reference test below +// asserts that every one of these resolves to a surviving command file or to +// a known consolidated parent (which absorbs flag-form invocations of folded +// skills, e.g. `gsd-map-codebase --fast` for the former `gsd-scan`). +const FLAG_FORM_PARENTS = new Set([ + 'gsd-code-review', // --fix absorbs former gsd-code-review-fix + 'gsd-map-codebase', // --fast absorbs scan, --query absorbs intel +]); + +/** + * Parse the leading YAML frontmatter block of a markdown file into a + * shallow `{ key: value }` map plus the trailing body. Splits on `\r?\n` + * for CRLF tolerance and uses trimmed-line equality for the `---` + * delimiters so whitespace-padded delimiter lines are accepted. + */ +function parseFrontmatter(content) { + const lines = content.split(/\r?\n/); + let openIdx = -1; + let closeIdx = -1; + for (let i = 0; i < lines.length; i += 1) { + if (lines[i].trim() === '---') { + if (openIdx === -1) openIdx = i; + else { closeIdx = i; break; } + } + } + assert.ok(openIdx !== -1 && closeIdx !== -1, 'frontmatter block must be delimited by --- on its own lines'); + const fm = {}; + for (const line of lines.slice(openIdx + 1, closeIdx)) { + const m = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/); + if (!m) continue; + const [, key, raw] = m; + const value = raw.trim().replace(/^["']|["']$/g, ''); + fm[key] = value; + } + fm._body = lines.slice(closeIdx + 1).join('\n'); + return fm; +} + +function readNamespaceFile(file) { + const filePath = path.join(COMMANDS_DIR, file); + assert.ok(fs.existsSync(filePath), `${file} must exist at ${filePath}`); + return { filePath, ...parseFrontmatter(fs.readFileSync(filePath, 'utf-8')) }; +} + +// ── Frontmatter contract ─────────────────────────────────────────────── + +describe('Namespace skill files exist with correct name', () => { + for (const { file, name } of NAMESPACE_SKILLS) { + test(`${file} — name field is hyphen-form ${name}`, () => { + const fm = readNamespaceFile(file); + assert.strictEqual( + fm.name, + name, + `name: in ${file} must be ${name} (hyphen form per #2858), got: ${fm.name}`, + ); + }); + } +}); + +describe('Namespace skill descriptions are keyword-tag format', () => { + for (const { file } of NAMESPACE_SKILLS) { + test(`${file} — description ≤ 60 chars`, () => { + const fm = readNamespaceFile(file); + assert.ok( + fm.description.length <= 60, + `${file} description must be ≤ 60 chars, got ${fm.description.length}: ${fm.description}`, + ); + }); + + test(`${file} — description contains a pipe separator`, () => { + const fm = readNamespaceFile(file); + assert.ok( + fm.description.includes('|'), + `${file} description must contain | pipe separator, got: ${fm.description}`, + ); + }); + + test(`${file} — description does not start with prose ("Use " / "This skill")`, () => { + const { description } = readNamespaceFile(file); + assert.ok( + !description.startsWith('Use ') && !description.startsWith('This skill'), + `${file} description must not start with "Use " or "This skill", got: ${description}`, + ); + }); + } +}); + +// ── allowed-tools must include Skill ────────────────────────────────── + +describe('Namespace skills permit Skill execution', () => { + for (const { file } of NAMESPACE_SKILLS) { + test(`${file} — allowed-tools includes Skill`, () => { + const filePath = path.join(COMMANDS_DIR, file); + const raw = fs.readFileSync(filePath, 'utf-8'); + const lines = raw.split(/\r?\n/); + const startIdx = lines.findIndex((l) => l.trim() === 'allowed-tools:'); + assert.ok(startIdx !== -1, `${file} must declare an allowed-tools block`); + const tools = []; + for (let i = startIdx + 1; i < lines.length; i += 1) { + const m = lines[i].match(/^\s+-\s+(\S+)/); + if (!m) break; + tools.push(m[1]); + } + assert.ok( + tools.includes('Skill'), + `${file} body invokes the Skill tool but allowed-tools does not include Skill (got: ${tools.join(', ')})`, + ); + }); + } +}); + +// ── Body contains routing table ─────────────────────────────────────── + +describe('Namespace skill bodies carry a routing table', () => { + for (const { file } of NAMESPACE_SKILLS) { + test(`${file} — body contains "| User wants" table header`, () => { + const fm = readNamespaceFile(file); + const lines = fm._body.split('\n'); + const hasHeader = lines.some((l) => l.includes('| User wants')); + assert.ok(hasHeader, `${file} body must contain a routing table starting with "| User wants"`); + }); + + test(`${file} — body has at least one Invoke target`, () => { + const fm = readNamespaceFile(file); + const hasInvoke = /\bgsd-[a-z-]+/i.test(fm._body); + assert.ok(hasInvoke, `${file} body must reference at least one gsd-* sub-skill`); + }); + } +}); + +// ── Context guard contract on gsd-health ────────────────────────────── +// Asserts the `--context` surface promised by #2792 is wired through to +// both the command frontmatter and the workflow body. The classifier +// itself is covered by tests/context-utilization.test.cjs and the SDK +// CLI by tests/validate-context.test.cjs. + +describe('gsd-health --context flag is wired into command + workflow', () => { + const HEALTH_CMD = path.join(COMMANDS_DIR, 'health.md'); + const HEALTH_WORKFLOW = path.join(__dirname, '..', 'gsd-core', 'workflows', 'health.md'); + + test('commands/gsd/health.md argument-hint advertises --context', () => { + const raw = fs.readFileSync(HEALTH_CMD, 'utf-8'); + const fm = parseFrontmatter(raw); + assert.ok( + fm['argument-hint'] && fm['argument-hint'].includes('--context'), + `health.md argument-hint must include --context, got: ${fm['argument-hint']}`, + ); + }); + + test('commands/gsd/health.md body documents the three-state utilization table', () => { + const raw = fs.readFileSync(HEALTH_CMD, 'utf-8'); + const body = parseFrontmatter(raw)._body.toLowerCase(); + assert.ok(body.includes('healthy'), 'body must name the healthy state'); + assert.ok(body.includes('warning'), 'body must name the warning state'); + assert.ok(body.includes('critical'), 'body must name the critical state'); + assert.ok( + body.includes('60%') && body.includes('70%'), + 'body must reference the 60% and 70% threshold boundaries', + ); + }); + + test('gsd-core/workflows/health.md has a context_check step', () => { + const raw = fs.readFileSync(HEALTH_WORKFLOW, 'utf-8'); + assert.match( + raw, + //, + 'workflow must define a branch', + ); + }); + + test('workflow context_check invokes gsd-sdk query validate.context', () => { + const raw = fs.readFileSync(HEALTH_WORKFLOW, 'utf-8'); + // Extract just the context_check step's body so a stray reference + // elsewhere in the file can't satisfy this assertion. + const stepMatch = raw.match(/([\s\S]*?)<\/step>/); + assert.ok(stepMatch, 'context_check step must be a closed ... block'); + const stepBody = stepMatch[1]; + // After #3797 architectural fix, callsites use gsd_run + assert.match( + stepBody, + /gsd_run\s+query\s+validate\.context/, + 'context_check must call `gsd_run query validate.context`', + ); + assert.match(stepBody, /--tokens-used/, 'context_check must pass --tokens-used'); + assert.match(stepBody, /--context-window/, 'context_check must pass --context-window'); + }); +}); + +// ── Namespace nesting completeness (#69) ────────────────────────────── +// Guards that the install-layout nesting invariant (<=6 top-level entries) +// is always satisfiable: every router's requires list points at real files, +// every concrete skill is covered by at least one router, and each router's +// body table stays in sync with its requires list. + +const NS_FILES = NAMESPACE_SKILLS.map((ns) => ns.file); + +describe('namespace nesting completeness (#69)', () => { + // Build the concrete-skill set once (all *.md minus ns-*.md) + const allFiles = fs.readdirSync(COMMANDS_DIR).filter((f) => f.endsWith('.md')); + const concreteStemSet = new Set( + allFiles + .filter((f) => !f.startsWith('ns-')) + .map((f) => f.replace(/\.md$/, '')), + ); + + // Build per-router requires and the union over all routers + const routerRequires = new Map(); // stem -> string[] + for (const f of NS_FILES) { + const stem = f.replace(/\.md$/, ''); + const content = fs.readFileSync(path.join(COMMANDS_DIR, f), 'utf-8'); + routerRequires.set(stem, parseRequires(content)); + } + const allRoutedStems = new Set([...routerRequires.values()].flat()); + + test('every router requires entry resolves to a real concrete skill file', () => { + const bad = []; + for (const [routerStem, children] of routerRequires) { + for (const child of children) { + if (!fs.existsSync(path.join(COMMANDS_DIR, `${child}.md`))) { + bad.push(`${routerStem} → ${child}`); + } + } + } + assert.deepStrictEqual( + bad, + [], + `Router requires entries with no matching commands/gsd/.md: ${bad.join(', ')}`, + ); + }); + + test('every concrete skill is routed by at least one namespace router', () => { + const unrouted = [...concreteStemSet].filter((stem) => !allRoutedStems.has(stem)); + assert.deepStrictEqual( + unrouted, + [], + `Concrete skills not routed by any ns-*.md (add to a router's requires:): ${unrouted.join(', ')}`, + ); + }); + + test("each router's routing-table rows reference only its own required sub-skills (plus flag variants)", () => { + const bad = []; + for (const [routerStem, children] of routerRequires) { + const childSet = new Set(children); + const content = fs.readFileSync(path.join(COMMANDS_DIR, `${routerStem}.md`), 'utf-8'); + const fm = parseFrontmatter(content); + // Extract gsd- tokens from table data rows (last cell), strip flags + for (const line of fm._body.split('\n')) { + if (!line.startsWith('|') || /^\|[\s\-:|]+\|?\s*$/.test(line)) continue; + const cells = line.split('|').map((c) => c.trim()).filter(Boolean); + if (cells.length < 2) continue; + const lastCell = cells[cells.length - 1]; + for (const match of lastCell.matchAll(/\bgsd-([a-z][a-z0-9-]*)/g)) { + const stem = match[1]; + if (!childSet.has(stem)) { + bad.push(`${routerStem}: body table references gsd-${stem} but it's not in requires`); + } + } + } + } + assert.deepStrictEqual( + bad, + [], + `Routing table / requires mismatch:\n${bad.join('\n')}`, + ); + }); +}); + +// ── Cross-reference: every routed sub-skill must exist ───────────────── +// This is the regression guard the original PR lacked. Without it, +// post-#2790 consolidations can quietly invalidate router targets again. + +describe('Namespace router targets resolve to surviving skills', () => { + // Build the post-consolidation surviving set once. + const surviving = new Set(); + for (const f of fs.readdirSync(COMMANDS_DIR)) { + if (!f.endsWith('.md')) continue; + const base = f.replace(/\.md$/, ''); + if (base.startsWith('ns-')) continue; // namespace routers themselves + surviving.add(`gsd-${base}`); + // The PR #2858 rename canonicalized extract_learnings → extract-learnings. + // Until #2790 rebases onto current main, accept either source filename + // as resolving to the canonical hyphenated identifier. + if (base === 'extract_learnings') surviving.add('gsd-extract-learnings'); + } + + for (const { file } of NAMESPACE_SKILLS) { + test(`${file} — every routing target resolves`, () => { + const fm = readNamespaceFile(file); + // Extract every gsd- token that appears in a table-row right column. + // Strip flag suffixes (`gsd-foo --bar` → `gsd-foo`) before resolving. + const targets = new Set(); + for (const line of fm._body.split('\n')) { + // Only consider markdown table data rows: lines that start with `|` + // and have content between pipes. Skip header / separator rows. + if (!line.startsWith('|') || /^\|[\s\-:|]+\|?\s*$/.test(line)) continue; + const cells = line.split('|').map((c) => c.trim()).filter(Boolean); + if (cells.length < 2) continue; + for (const m of cells[cells.length - 1].matchAll(/\bgsd-[a-z][a-z0-9-]*/g)) { + targets.add(m[0]); + } + } + assert.ok(targets.size > 0, `${file} routing table must reference at least one gsd-* target`); + const unresolved = [...targets].filter( + (t) => !surviving.has(t) && !FLAG_FORM_PARENTS.has(t), + ); + assert.deepStrictEqual( + unresolved, + [], + `${file} routes to skills that don't exist in commands/gsd/: ${unresolved.join(', ')}`, + ); + }); + } +}); + }); +} diff --git a/tests/state.test.cjs b/tests/state.test.cjs index c48f92251..7167579e6 100644 --- a/tests/state.test.cjs +++ b/tests/state.test.cjs @@ -8995,3 +8995,1140 @@ describe('flat "## Phase Details" milestone leak (#501)', () => { }); }); } + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-1967-cache-invalidation.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-1967-cache-invalidation (consolidation epic #1969 B3 #1972)", () => { +// allow-test-rule: source-text-is-the-product (see #1967) +// 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 tests for #1967 cache invalidation. + * + * The disk scan cache in buildStateFrontmatter must be invalidated on + * writeStateMd to prevent stale reads if multiple state-mutating + * operations occur within the same Node process. This matters for: + * - SDK callers that require() gsd-tools.cjs as a module + * - Future dispatcher extensions that handle compound operations + * - Tests that import state.cjs directly + */ + +'use strict'; + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); + +const state = require('../gsd-core/bin/lib/state.cjs'); +const { cleanup } = require('./helpers.cjs'); + +describe('buildStateFrontmatter cache invalidation (#1967)', () => { + let tmpDir; + let planningDir; + let phasesDir; + let statePath; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1967-cache-')); + planningDir = path.join(tmpDir, '.planning'); + phasesDir = path.join(planningDir, 'phases'); + fs.mkdirSync(phasesDir, { recursive: true }); + + // Create a minimal config and STATE.md + fs.writeFileSync( + path.join(planningDir, 'config.json'), + JSON.stringify({ project_code: 'TEST' }) + ); + + statePath = path.join(planningDir, 'STATE.md'); + fs.writeFileSync(statePath, [ + '# State', + '', + '**Current Phase:** 1', + '**Status:** executing', + '**Total Phases:** 2', + '', + ].join('\n')); + + // Start with one phase directory containing one PLAN + const phase1 = path.join(phasesDir, '01-foo'); + fs.mkdirSync(phase1); + fs.writeFileSync(path.join(phase1, '01-1-PLAN.md'), '---\nphase: 1\nplan: 1\n---\n# Plan\n'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('writeStateMd invalidates cache so subsequent reads see new disk state', () => { + // First write — populates cache via buildStateFrontmatter + const content1 = fs.readFileSync(statePath, 'utf-8'); + state.writeStateMd(statePath, content1, tmpDir); + + // Create a NEW phase directory AFTER the first write + // Without cache invalidation, the second write would still see only 1 phase + const phase2 = path.join(phasesDir, '02-bar'); + fs.mkdirSync(phase2); + fs.writeFileSync(path.join(phase2, '02-1-PLAN.md'), '---\nphase: 2\nplan: 1\n---\n# Plan\n'); + fs.writeFileSync(path.join(phase2, '02-1-SUMMARY.md'), '---\nstatus: complete\n---\n# Summary\n'); + + // Second write in the SAME process — must see the new phase + const content2 = fs.readFileSync(statePath, 'utf-8'); + state.writeStateMd(statePath, content2, tmpDir); + + // Read back and parse frontmatter to verify it reflects 2 phases, not 1 + const result = fs.readFileSync(statePath, 'utf-8'); + const fmMatch = result.match(/^---\r?\n([\s\S]*?)\r?\n---/); + assert.ok(fmMatch, 'STATE.md should have frontmatter after writeStateMd'); + + const fm = fmMatch[1]; + // Should show 2 total phases (the new disk state), not 1 (stale cache) + const totalPhasesMatch = fm.match(/total_phases:\s*(\d+)/); + assert.ok(totalPhasesMatch, 'frontmatter should contain total_phases'); + assert.strictEqual( + parseInt(totalPhasesMatch[1], 10), + 2, + 'total_phases should reflect new disk state (2), not stale cache (1)' + ); + + // Should show 1 completed phase (phase 2 has SUMMARY) + const completedMatch = fm.match(/completed_phases:\s*(\d+)/); + assert.ok(completedMatch, 'frontmatter should contain completed_phases'); + assert.strictEqual( + parseInt(completedMatch[1], 10), + 1, + 'completed_phases should reflect new disk state (1 complete), not stale cache (0)' + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3127-state-begin-phase-idempotent.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3127-state-begin-phase-idempotent (consolidation epic #1969 B3 #1972)", () => { +'use strict'; +// allow-test-rule: reads runtime STATE.md written to temp dir — behavioral output test, not source-grep (see #3127) + +// Regression tests for bug #3127. +// +// state.begin-phase is non-idempotent: when execute-phase calls it on a phase +// that is already mid-flight (e.g. --wave N resume), the handler unconditionally +// overwrites execution-progress fields with stale values from the last plan-phase run: +// - stopped_at / Last Activity Description reset to "context gathered; ready for plan-phase" +// - Current Plan reset to 1 (from plan being executed, e.g. 3) +// - Plan: N of M body line reset to "Plan: 1 of M" +// - Last activity timestamp reverted to an older value +// - progress.percent may decrease +// +// Fix: read the current Status field before writing. If the phase is already +// "Executing Phase N", skip the execution-progress fields (Current Plan, plan body +// line, Last Activity Description) and only update fields safe to overwrite on +// resume (Last Activity date, Status if somehow wrong). +// A --force flag bypasses the guard for intentional full resets. + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { cleanup } = require('./helpers.cjs'); + +const ROOT = path.join(__dirname, '..'); + +// Load the state.cjs module internals via the command router +function requireStateCjs() { + return require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'state.cjs')); +} + +function makeTempPlanning(stateContent) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3127-')); + const planningDir = path.join(dir, '.planning'); + fs.mkdirSync(planningDir, { recursive: true }); + fs.writeFileSync(path.join(planningDir, 'STATE.md'), stateContent, 'utf8'); + return dir; +} + +// A STATE.md that is mid-flight on Phase 5 (Plan 3 of 8 in progress) +const MID_FLIGHT_STATE = `# GSD State + +## Configuration +Current Phase: 5 +Current Phase Name: test-phase +Total Plans in Phase: 8 +Current Plan: 3 +Status: Executing Phase 5 + +## Current Position + +Phase: 5 (test-phase) — EXECUTING +Plan: 3 of 8 (Plan 00 SHIPPED — wave 1 complete; Plan 01 SHIPPED; Plan 02 next) +Status: Executing Phase 5 +Last activity: 2026-05-05 -- Plan 02 SHIPPED wave 2 GREEN + +## Progress + +progress: + total_phases: 10 + completed_phases: 4 + percent: 89 + +stopped_at: Phase 5 Plan 02 SHIPPED — Wave 2 GREEN detailed narrative here; ready for Plan 03 +`; + +// A STATE.md that is NOT yet executing (plan-phase just ran) +const PRE_EXECUTE_STATE = `# GSD State + +## Configuration +Current Phase: 5 +Current Phase Name: test-phase +Total Plans in Phase: 8 +Current Plan: 1 +Status: Ready to execute + +## Current Position + +Phase: 5 (test-phase) — READY +Plan: 1 of 8 +Status: Ready to execute +Last activity: 2026-05-04 -- context gathered; ready for plan-phase + +stopped_at: Phase 5 context gathered; ready for plan-phase +`; + +describe('bug #3127: state.begin-phase idempotency guard', () => { + test('begin-phase on a mid-flight phase does not reset Current Plan', () => { + const stateModule = requireStateCjs(); + const { cmdStateBeginPhase } = stateModule; + if (!cmdStateBeginPhase) { + // Skip if not exported — the guard may be inside a private function + return; + } + const dir = makeTempPlanning(MID_FLIGHT_STATE); + try { + cmdStateBeginPhase(dir, '5', 'test-phase', 8, false); + const after = fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8'); + // Current Plan must not have been reset to 1 + const planMatch = after.match(/^Current Plan:\s*(\S+)/m); + if (planMatch) { + assert.notStrictEqual(planMatch[1], '1', + 'begin-phase reset Current Plan to 1 on a mid-flight phase — idempotency guard not applied'); + } + } finally { + cleanup(dir); + } + }); + + test('begin-phase on a mid-flight phase does not overwrite stopped_at narrative', () => { + const stateModule = requireStateCjs(); + const { cmdStateBeginPhase } = stateModule; + if (!cmdStateBeginPhase) return; + const dir = makeTempPlanning(MID_FLIGHT_STATE); + try { + cmdStateBeginPhase(dir, '5', 'test-phase', 8, false); + const after = fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8'); + // The rich stopped_at narrative must be preserved + assert.ok( + after.includes('Plan 02 SHIPPED') || after.includes('Wave 2 GREEN'), + 'begin-phase overwrote stopped_at narrative on a mid-flight phase', + ); + } finally { + cleanup(dir); + } + }); + + test('begin-phase on a NOT-yet-executing phase sets Current Plan to 1 (normal path)', () => { + const stateModule = requireStateCjs(); + const { cmdStateBeginPhase } = stateModule; + if (!cmdStateBeginPhase) return; + const dir = makeTempPlanning(PRE_EXECUTE_STATE); + try { + cmdStateBeginPhase(dir, '5', 'test-phase', 8, false); + const after = fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8'); + // Normal path: Current Plan should become 1 (or stay 1) + const planMatch = after.match(/^Current Plan:\s*(\S+)/m); + if (planMatch) { + assert.strictEqual(planMatch[1], '1', + 'begin-phase should set Current Plan to 1 on a fresh phase'); + } + } finally { + cleanup(dir); + } + }); + + test('begin-phase always updates Last Activity date (safe on resume, pinned via GSD_NOW_MS)', () => { + const stateModule = requireStateCjs(); + const { cmdStateBeginPhase } = stateModule; + if (!cmdStateBeginPhase) return; + const dir = makeTempPlanning(MID_FLIGHT_STATE); + + const PINNED_MS = Date.parse('2020-11-25T09:00:00.000Z'); + const PINNED_DATE = '2020-11-25'; + // Pin the in-process clock via env vars before calling the function directly. + const origTestMode = process.env.GSD_TEST_MODE; + const origNowMs = process.env.GSD_NOW_MS; + process.env.GSD_TEST_MODE = '1'; + process.env.GSD_NOW_MS = String(PINNED_MS); + try { + cmdStateBeginPhase(dir, '5', 'test-phase', 8, false); + const after = fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8'); + assert.ok( + after.includes(PINNED_DATE), + `begin-phase must update Last Activity date to the pinned date ${PINNED_DATE} even on resume (safe field)`, + ); + } finally { + // Restore env vars before cleanup to avoid leaking state to other tests. + if (origTestMode === undefined) delete process.env.GSD_TEST_MODE; + else process.env.GSD_TEST_MODE = origTestMode; + if (origNowMs === undefined) delete process.env.GSD_NOW_MS; + else process.env.GSD_NOW_MS = origNowMs; + cleanup(dir); + } + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/fix-1445-999x-backlog-excluded-from-total-phases.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:fix-1445-999x-backlog-excluded-from-total-phases (consolidation epic #1969 B3 #1972)", () => { +'use strict'; +/** + * Regression test for bug #1445: + * 999.x backlog phases must not be counted toward total_phases. + * + * Root cause: + * deriveProgressFromRoadmap (phase-lifecycle.cts) counted ALL data rows + * matching /^\|\s*\d+/ in the progress table, including 999.x backlog rows. + * Similarly, state.cts's roadmapPhaseCount loop (via extractCurrentMilestone) + * counted 999.x phase headings because it only checked /\d/.test(m[1]). + * + * Fix: + * Both sites now test /^999(?:\.|$)/.test(token) and skip matching rows. + * Mirrors the existing init.cts /^999(?:\.|$)/ filter. + * + * Scenarios: + * A. deriveProgressFromRoadmap with a progress table containing a 999.x row. + * B. state json total_phases via extractCurrentMilestone / roadmapPhaseCount. + * + * Follow-up #1580: the same `^999` (and Phase 0) sentinel exclusion was missing + * in two more code paths — `milestone complete`'s unstarted-phase guard + * (src/milestone.cts) and `roadmap analyze`'s next_phase routing + phase_count + * (src/roadmap.cts). Scenarios C and D below cover those. + * + * C. milestone complete is NOT blocked by a Phase 999 backlog heading. + * D. roadmap analyze never routes next_phase to 999 / never counts it. + */ + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); +const { deriveProgressFromRoadmap } = require('../gsd-core/bin/lib/phase-lifecycle.cjs'); + +// ─── Scenario A: deriveProgressFromRoadmap unit test ──────────────────────── + +describe('bug #1445 — deriveProgressFromRoadmap excludes 999.x rows', () => { + test('3 real phases + 1 999.x backlog row → total_phases: 3, not 4', () => { + const roadmap = [ + '## Milestone v1.0: Test', + '', + '| Phase | Plans | Status | Completed |', + '| --- | --- | --- | --- |', + '| 1. Alpha | 2/2 | Complete | ✅ |', + '| 2. Beta | 1/2 | In Progress | |', + '| 3. Gamma | 0/1 | Planned | |', + '| 999.1 Backlog: Future Idea | 0/0 | Backlog | |', + ].join('\n'); + + const result = deriveProgressFromRoadmap(roadmap); + assert.equal( + result.totalPhases, + 3, + `total_phases must be 3 (not 4) — 999.1 backlog row must be excluded. Got ${result.totalPhases}`, + ); + assert.equal( + result.completedPhases, + 1, + `completed_phases must be 1. Got ${result.completedPhases}`, + ); + }); + + test('999 exact (no dot) row is also excluded', () => { + const roadmap = [ + '## Milestone v1.0: Test', + '', + '| Phase | Plans | Status | Completed |', + '| --- | --- | --- | --- |', + '| 1. Alpha | 1/1 | Complete | ✅ |', + '| 2. Beta | 1/1 | Complete | ✅ |', + '| 999 Backlog | 0/0 | Backlog | |', + ].join('\n'); + + const result = deriveProgressFromRoadmap(roadmap); + assert.equal( + result.totalPhases, + 2, + `total_phases must be 2 (not 3) — 999 row must be excluded. Got ${result.totalPhases}`, + ); + assert.equal( + result.completedPhases, + 2, + `completed_phases must be 2. Got ${result.completedPhases}`, + ); + }); + + test('all-backlog table yields null total_phases (no real phases)', () => { + const roadmap = [ + '## Milestone v1.0: Test', + '', + '| Phase | Plans | Status | Completed |', + '| --- | --- | --- | --- |', + '| 999.1 Future A | 0/0 | Backlog | |', + '| 999.2 Future B | 0/0 | Backlog | |', + ].join('\n'); + + const result = deriveProgressFromRoadmap(roadmap); + assert.equal( + result.totalPhases, + null, + `total_phases must be null when the only rows are 999.x backlog. Got ${result.totalPhases}`, + ); + }); +}); + +// ─── Scenario B: state json total_phases via roadmapPhaseCount ─────────────── + +describe('bug #1445 — state json excludes 999.x phase headings from total_phases', () => { + let tmpDir; + + const ROADMAP = [ + '## Milestone v1.0: Test Milestone', + '', + '### Phase 01: Alpha', + '**Goal:** first', + '', + '### Phase 02: Beta', + '**Goal:** second', + '', + '### Phase 03: Gamma', + '**Goal:** third', + '', + '### Phase 999.1: Backlog Item A', + '**Goal:** future idea, not counted', + '', + '### Phase 999.2: Backlog Item B', + '**Goal:** another future idea', + ].join('\n'); + + beforeEach(() => { + tmpDir = createTempProject('bug-1445-'); + const planning = path.join(tmpDir, '.planning'); + fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP, 'utf-8'); + fs.writeFileSync( + path.join(planning, 'STATE.md'), + [ + '---', + 'gsd_state_version: 1.0', + 'milestone: v1.0', + 'status: executing', + '---', + '', + '# GSD State', + '', + '## Configuration', + 'Current Phase: 1', + 'Status: Executing Phase 1', + 'Last Activity: 2026-01-01', + ].join('\n'), + 'utf-8', + ); + fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8'); + + for (const d of ['01-alpha', '02-beta', '03-gamma']) { + const dir = path.join(planning, 'phases', d); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'PLAN.md'), '# Plan\n', 'utf-8'); + } + // 999.x dirs should exist on disk but must not inflate total_phases + for (const d of ['999.1-backlog-a', '999.2-backlog-b']) { + fs.mkdirSync(path.join(planning, 'phases', d), { recursive: true }); + } + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('state json total_phases is 3, not 5 (999.x dirs and headings excluded)', () => { + const result = runGsdTools(['state', 'json'], tmpDir); + assert.ok(result.success, `state json failed: ${result.error}`); + const state = JSON.parse(result.output); + assert.ok(state.progress, 'state json must return a progress block'); + assert.equal( + state.progress.total_phases, + 3, + `total_phases must be 3 (not 5). 999.x backlog phases must be excluded. Got ${state.progress.total_phases}`, + ); + }); +}); + +// ─── Scenario C: milestone complete not blocked by a 999 backlog heading ───── + +describe('fix #1580 — milestone complete ignores the 999 backlog sentinel', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject('fix-1580-mc-'); + const planning = path.join(tmpDir, '.planning'); + // One real, on-disk phase + a directory-less Phase 999 backlog heading. + fs.writeFileSync( + path.join(planning, 'ROADMAP.md'), + [ + '# Roadmap v1.0', + '## v1.0 Milestone', + '## Phases', + '- [x] **Phase 1: Foundation**', + '## Phase Details', + '### Phase 1: Foundation', + '**Goal:** build it', + '### Phase 999: Backlog / Someday', + '**Goal:** deferred, never executed', + ].join('\n'), + 'utf-8', + ); + fs.writeFileSync( + path.join(planning, 'STATE.md'), + `---\nmilestone: v1.0\n---\n# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`, + 'utf-8', + ); + const dir = path.join(planning, 'phases', '01-foundation'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'PLAN.md'), '# Plan\n', 'utf-8'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('completes WITHOUT --force despite a Phase 999 backlog heading', () => { + const result = runGsdTools( + ['milestone', 'complete', 'v1.0', '--name', 'Regression'], + tmpDir, + ); + assert.ok( + result.success, + `milestone complete must not be blocked by the 999 sentinel; got error: ${result.error}`, + ); + assert.ok( + !/Cannot mark milestone complete/.test(result.error || ''), + `the unstarted-phase guard must not fire on Phase 999. Got: ${result.error}`, + ); + }); +}); + +// ─── Scenario D: roadmap analyze never routes/ counts the 999 sentinel ──────── + +describe('fix #1580 — roadmap analyze excludes the 999 backlog sentinel', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject('fix-1580-ra-'); + const planning = path.join(tmpDir, '.planning'); + fs.writeFileSync( + path.join(planning, 'ROADMAP.md'), + [ + '# Roadmap v1.0', + '## v1.0 Milestone', + '## Phases', + '- [x] **Phase 1: Foundation**', + '## Phase Details', + '### Phase 1: Foundation', + '**Goal:** build it', + '### Phase 999: Backlog / Someday', + '**Goal:** deferred, never executed', + ].join('\n'), + 'utf-8', + ); + fs.writeFileSync( + path.join(planning, 'STATE.md'), + `---\nmilestone: v1.0\n---\n# State\n`, + 'utf-8', + ); + const dir = path.join(planning, 'phases', '01-foundation'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'PLAN.md'), '# Plan\n', 'utf-8'); + fs.writeFileSync(path.join(dir, 'SUMMARY.md'), '# Summary\n', 'utf-8'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('next_phase is never 999 and phase_count excludes the sentinel', () => { + const result = runGsdTools(['roadmap', 'analyze', '--raw'], tmpDir); + assert.ok(result.success, `roadmap analyze failed: ${result.error}`); + const analysis = JSON.parse(result.output); + assert.notEqual( + String(analysis.next_phase), + '999', + `next_phase must never route to the 999 backlog sentinel. Got ${analysis.next_phase}`, + ); + assert.equal( + analysis.phase_count, + 1, + `phase_count must exclude the 999 sentinel (expected 1). Got ${analysis.phase_count}`, + ); + assert.ok( + !(analysis.phases || []).some(p => String(p.number) === '999'), + 'the phases array must not include the 999 backlog sentinel', + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/fix-1446-total-phases-corrects-downward.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:fix-1446-total-phases-corrects-downward (consolidation epic #1969 B3 #1972)", () => { +'use strict'; +/** + * Regression test for bug #1446: + * total_phases must correct downward when re-derived; shouldPreserveExistingProgress + * must NOT include total_phases in its ratchet check. + * + * Root cause: + * shouldPreserveExistingProgress (state-document.cts) returned true when + * existingProgress.total_phases > derivedProgress.total_phases, making the + * stored value sticky even when it was wrong (e.g. counted backlog phases). + * + * Fix: + * total_phases is removed from the "existing exceeds derived" check. + * Only completed_phases, total_plans, and completed_plans keep ratchet behaviour. + * + * Scenarios: + * A. shouldPreserveExistingProgress unit test — returns false when only total_phases differs. + * B. state sync re-derives a lower total_phases and writes the new value. + */ + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); +const { shouldPreserveExistingProgress } = require('../gsd-core/bin/lib/state-document.cjs'); + +// ─── Scenario A: unit test ─────────────────────────────────────────────────── + +describe('bug #1446 — shouldPreserveExistingProgress does not ratchet total_phases', () => { + test('existing total_phases:10 > derived total_phases:7 → returns false (no ratchet)', () => { + const existing = { total_phases: 10, completed_phases: 3, total_plans: 6, completed_plans: 3 }; + const derived = { total_phases: 7, completed_phases: 3, total_plans: 6, completed_plans: 3 }; + assert.equal( + shouldPreserveExistingProgress(existing, derived), + false, + 'total_phases downward correction must NOT trigger shouldPreserveExistingProgress', + ); + }); + + test('existing completed_phases:5 > derived completed_phases:2 → returns true (ratchet still active)', () => { + const existing = { total_phases: 7, completed_phases: 5, total_plans: 6, completed_plans: 3 }; + const derived = { total_phases: 7, completed_phases: 2, total_plans: 6, completed_plans: 3 }; + assert.equal( + shouldPreserveExistingProgress(existing, derived), + true, + 'completed_phases ratchet must still work', + ); + }); + + test('existing total_phases:10 > derived:7 AND completed_phases matches → false (total_phases alone does not preserve)', () => { + const existing = { total_phases: 10, completed_phases: 3 }; + const derived = { total_phases: 7, completed_phases: 3 }; + assert.equal( + shouldPreserveExistingProgress(existing, derived), + false, + 'only-total_phases discrepancy must not trigger preservation', + ); + }); + + test('all derived values equal existing → returns false', () => { + const existing = { total_phases: 7, completed_phases: 3, total_plans: 6, completed_plans: 3 }; + const derived = { total_phases: 7, completed_phases: 3, total_plans: 6, completed_plans: 3 }; + assert.equal(shouldPreserveExistingProgress(existing, derived), false); + }); +}); + +// ─── Scenario B: end-to-end state sync overwrites inflated total_phases ────── + +describe('bug #1446 — state sync writes corrected (lower) total_phases', () => { + let tmpDir; + + // ROADMAP has 3 real phases only (no 999.x). + const ROADMAP = [ + '## Milestone v1.0: Test', + '', + '### Phase 01: Alpha', + '**Goal:** alpha', + '', + '### Phase 02: Beta', + '**Goal:** beta', + '', + '### Phase 03: Gamma', + '**Goal:** gamma', + ].join('\n'); + + beforeEach(() => { + tmpDir = createTempProject('bug-1446-'); + const planning = path.join(tmpDir, '.planning'); + fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP, 'utf-8'); + + // STATE.md has a stale inflated total_phases:10 in frontmatter. + fs.writeFileSync( + path.join(planning, 'STATE.md'), + [ + '---', + 'gsd_state_version: 1.0', + 'milestone: v1.0', + 'status: executing', + 'progress:', + ' total_phases: 10', + ' completed_phases: 2', + ' total_plans: 6', + ' completed_plans: 4', + ' percent: 40', + '---', + '', + '# GSD State', + '', + '## Configuration', + 'Current Phase: 3', + 'Status: Executing Phase 3', + 'Last Activity: 2026-01-01', + 'Progress: [████░░░░░░] 40%', + ].join('\n'), + 'utf-8', + ); + fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8'); + + for (const d of ['01-alpha', '02-beta', '03-gamma']) { + const dir = path.join(planning, 'phases', d); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'PLAN.md'), '# Plan\n', 'utf-8'); + // Mark 01 and 02 as complete (2 summaries) + if (d !== '03-gamma') { + fs.writeFileSync(path.join(dir, 'PLAN-SUMMARY.md'), '# Summary\n', 'utf-8'); + } + } + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('state sync corrects total_phases from 10 to 3', () => { + const syncResult = runGsdTools(['state', 'sync'], tmpDir); + assert.ok(syncResult.success, `state sync failed: ${syncResult.error}`); + + const jsonResult = runGsdTools(['state', 'json'], tmpDir); + assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`); + const state = JSON.parse(jsonResult.output); + + assert.ok(state.progress, 'state json must return a progress block'); + assert.equal( + state.progress.total_phases, + 3, + `total_phases must be corrected to 3 (derived), not kept at 10 (stale). Got ${state.progress.total_phases}`, + ); + // completed_phases ratchet still works: existing 2 ≥ disk-derived → keep 2 + assert.ok( + state.progress.completed_phases >= 2, + `completed_phases must be at least 2 (ratchet). Got ${state.progress.completed_phases}`, + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/fix-1514-retired-phase-excluded-from-total-phases.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:fix-1514-retired-phase-excluded-from-total-phases (consolidation epic #1969 B3 #1972)", () => { +'use strict'; +/** + * Regression test for bug #1514: + * A retired/folded phase (struck through in ROADMAP, marked `[x]`, with a + * directory but no completion artifact) must NOT be counted in + * progress.total_phases. Otherwise it inflates the denominator without ever + * satisfying the numerator (no SUMMARY → never "completed"), freezing a + * fully-shipped milestone below 100%. + * + * Root cause: + * buildStateFrontmatter (state.cts) derived total_phases from + * max(phaseDirs.length, roadmapPhaseCount) — both of which counted the + * retired phase (its directory and its `### Phase NN:` heading) — while + * completed_phases came from a disk SUMMARY scan that the retired phase + * can never satisfy. Same counting family as #549 / #500 / #1445. + * + * Fix: + * buildStateFrontmatter now extracts retired phase numbers from the GFM + * strikethrough in the current-milestone ROADMAP scope and excludes them + * from BOTH the disk phase-dir set and the heading count, so a retired + * phase counts toward neither denominator nor numerator. + * + * Why integration (state json) not a unit test: the bug only manifests in the + * assembled progress block a shipped milestone actually writes to STATE.md, so + * the test reproduces that artifact rather than a helper in isolation. + */ + +const { describe, test, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); +const fc = require('./helpers/fast-check-setup.cjs'); +const { _extractRetiredPhaseNumbers } = require('../gsd-core/bin/lib/state.cjs'); +const { normalizePhaseName } = require('../gsd-core/bin/lib/phase-id.cjs'); + +// Six phases, all shipped, except Phase 04 which is retired/folded into 05. +// Phases 01-03,05,06 have PLAN+SUMMARY (complete); Phase 04 keeps a directory +// but no work (retired). `complete` flags which dirs get PLAN+SUMMARY. +function seedProject(prefix, roadmap, completeDirs) { + const tmpDir = createTempProject(prefix); + const planning = path.join(tmpDir, '.planning'); + fs.writeFileSync(path.join(planning, 'ROADMAP.md'), roadmap, 'utf-8'); + fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8'); + fs.writeFileSync( + path.join(planning, 'STATE.md'), + [ + '---', + 'gsd_state_version: 1.0', + 'milestone: v1.0', + 'status: executing', + '---', + '', + '# GSD State', + '', + '## Configuration', + 'Current Phase: 6', + 'Status: shipped', + 'Last Activity: 2026-06-01', + ].join('\n'), + 'utf-8', + ); + const allDirs = ['01-alpha', '02-beta', '03-gamma', '04-delta', '05-epsilon', '06-zeta']; + for (const d of allDirs) { + const dir = path.join(planning, 'phases', d); + fs.mkdirSync(dir, { recursive: true }); + if (completeDirs.includes(d)) { + fs.writeFileSync(path.join(dir, 'PLAN.md'), '# Plan\n', 'utf-8'); + fs.writeFileSync(path.join(dir, 'SUMMARY.md'), '# Summary\n', 'utf-8'); + } + } + return tmpDir; +} + +const PHASE_DETAILS = [ + '### Phase 01: Alpha', '**Goal:** a', '', + '### Phase 02: Beta', '**Goal:** b', '', + '### Phase 03: Gamma', '**Goal:** c', '', + '### Phase 04: Delta', '**Goal:** GOAL_04', '', + '### Phase 05: Epsilon', '**Goal:** e', '', + '### Phase 06: Zeta', '**Goal:** f', +]; + +function roadmap(checklist04, goal04) { + return [ + '## Milestone v1.0: Repro', + '', + '### Phases', + '- [x] **Phase 01: Alpha** — done', + '- [x] **Phase 02: Beta** — done', + '- [x] **Phase 03: Gamma** — done', + checklist04, + '- [x] **Phase 05: Epsilon** — done', + '- [x] **Phase 06: Zeta** — done', + '', + ...PHASE_DETAILS.map((l) => (l === '**Goal:** GOAL_04' ? `**Goal:** ${goal04}` : l)), + ].join('\n'); +} + +const ALL_COMPLETE = ['01-alpha', '02-beta', '03-gamma', '05-epsilon', '06-zeta']; + +describe('bug #1514 — retired/folded phase excluded from progress.total_phases', () => { + let tmpDir; + afterEach(() => { + if (tmpDir) cleanup(tmpDir); + tmpDir = undefined; + }); + + test('struck `[x] ~~Phase 04~~ — folded into Phase 05` → 5/5, percent 100 (not 5/6, 83)', () => { + const rm = roadmap( + '- [x] ~~**Phase 04: Delta**~~ — folded into Phase 05; number retired', + 'folded into Phase 05', + ); + tmpDir = seedProject('bug-1514-a-', rm, ALL_COMPLETE); + const result = runGsdTools(['state', 'json'], tmpDir); + assert.ok(result.success, `state json failed: ${result.error}`); + const { progress } = JSON.parse(result.output); + assert.equal(progress.total_phases, 5, `total_phases must exclude the retired phase. Got ${progress.total_phases}`); + assert.equal(progress.completed_phases, 5, `completed_phases must be 5. Got ${progress.completed_phases}`); + assert.equal(progress.percent, 100, `shipped milestone must reach 100%. Got ${progress.percent}`); + }); + + test('fold TARGET is not retired: a struck goal line `~~folded into Phase 05~~` must not drop Phase 05', () => { + // Phase 04 retired via checklist; Phase 04 *goal* also struck and mentions + // the fold target. The target (Phase 05) must remain a counted phase. + const rm = roadmap( + '- [x] ~~**Phase 04: Delta**~~ — folded into Phase 05; number retired', + '~~folded into Phase 05; retired~~', + ); + tmpDir = seedProject('bug-1514-b-', rm, ALL_COMPLETE); + const result = runGsdTools(['state', 'json'], tmpDir); + assert.ok(result.success, `state json failed: ${result.error}`); + const { progress } = JSON.parse(result.output); + assert.equal(progress.total_phases, 5, `only Phase 04 is retired; Phase 05 must still count. Got ${progress.total_phases}`); + assert.equal(progress.completed_phases, 5, `completed_phases must be 5. Got ${progress.completed_phases}`); + assert.equal(progress.percent, 100, `Got ${progress.percent}`); + }); + + test('regression: no strikethrough → all 6 phases counted (6/6, 100)', () => { + const rm = roadmap('- [x] **Phase 04: Delta** — done', 'd'); + tmpDir = seedProject('bug-1514-c-', rm, [...ALL_COMPLETE, '04-delta']); + const result = runGsdTools(['state', 'json'], tmpDir); + assert.ok(result.success, `state json failed: ${result.error}`); + const { progress } = JSON.parse(result.output); + assert.equal(progress.total_phases, 6, `no retired phase: all 6 counted. Got ${progress.total_phases}`); + assert.equal(progress.completed_phases, 6, `Got ${progress.completed_phases}`); + assert.equal(progress.percent, 100, `Got ${progress.percent}`); + }); + + // `state sync --verify` is the SECOND counting path (cmdStateSync). Before the + // fix it re-derived the same inflated denominator and reported "no drift", + // so a manual STATE edit was the only recourse (#1514). It must now agree + // with state json and drive the stuck 83% Progress field to 100%. + test('state sync --verify drives a stuck 83% Progress to 100% (cmdStateSync path)', () => { + const rm = roadmap( + '- [x] ~~**Phase 04: Delta**~~ — folded into Phase 05; number retired', + 'folded into Phase 05', + ); + tmpDir = seedProject('bug-1514-sync-', rm, ALL_COMPLETE); + // Seed a stuck Progress line that the inflated denominator would "agree" with. + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + fs.appendFileSync(statePath, '\nProgress: [████████░░] 83%\n', 'utf-8'); + const result = runGsdTools(['state', 'sync', '--verify'], tmpDir); + assert.ok(result.success, `state sync --verify failed: ${result.error}`); + const { changes } = JSON.parse(result.output); + const progressChange = (changes || []).find((c) => /Progress:/.test(c)); + assert.ok(progressChange, `expected a Progress drift, got changes: ${JSON.stringify(changes)}`); + assert.match(progressChange, /-> .*100%/, `sync must want 100%, got: ${progressChange}`); + }); +}); + +// ─── Generic seeder for non-canonical phase shapes ────────────────────────── + +/** + * Seed a project from explicit phase specs so project-code, decimal, + * no-directory, and shipped-then-retired shapes can be exercised. + * spec: { id, retired?, dir?, shipped? } + * id — ROADMAP phase id (e.g. '04', '05.1', 'PROJ-42') + * retired — strike the checklist entry (folded/retired) + * dir — directory name to create (omit → no directory) + * shipped — write PLAN+SUMMARY into the directory (complete) + */ +function seedFromSpecs(prefix, specs) { + const tmpDir = createTempProject(prefix); + const planning = path.join(tmpDir, '.planning'); + const checklist = specs.map((s) => + s.retired + ? `- [x] ~~**Phase ${s.id}: P${s.id}**~~ — retired` + : `- [x] **Phase ${s.id}: P${s.id}** — done`, + ); + const details = specs.flatMap((s) => [`### Phase ${s.id}: P${s.id}`, '**Goal:** g', '']); + const roadmapText = ['## Milestone v1.0: Specs', '', '### Phases', ...checklist, '', ...details].join('\n'); + fs.writeFileSync(path.join(planning, 'ROADMAP.md'), roadmapText, 'utf-8'); + fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8'); + fs.writeFileSync( + path.join(planning, 'STATE.md'), + ['---', 'gsd_state_version: 1.0', 'milestone: v1.0', 'status: executing', '---', '', '# GSD State', '', '## Configuration', 'Current Phase: 1'].join('\n'), + 'utf-8', + ); + for (const s of specs) { + if (!s.dir) continue; + const dir = path.join(planning, 'phases', s.dir); + fs.mkdirSync(dir, { recursive: true }); + if (s.shipped) { + fs.writeFileSync(path.join(dir, 'PLAN.md'), '# Plan\n', 'utf-8'); + fs.writeFileSync(path.join(dir, 'SUMMARY.md'), '# Summary\n', 'utf-8'); + } + } + return tmpDir; +} + +describe('bug #1514 — retired exclusion across phase shapes', () => { + let tmpDir; + afterEach(() => { + if (tmpDir) cleanup(tmpDir); + tmpDir = undefined; + }); + + test('project-code retired phase is dropped from the denominator (Phase PROJ-42)', () => { + // Project-code dirs are not milestone-mapped for completion counts (a + // separate pre-existing limitation), so assert only the total_phases + // denominator, which #1514 governs: the struck PROJ-42 heading must not + // be counted, while PROJ-41 / PROJ-43 still are. + tmpDir = seedFromSpecs('bug-1514-pc-', [ + { id: 'PROJ-41', dir: 'PROJ-41-a', shipped: true }, + { id: 'PROJ-42', retired: true, dir: 'PROJ-42-d' }, + { id: 'PROJ-43', dir: 'PROJ-43-c', shipped: true }, + ]); + const result = runGsdTools(['state', 'json'], tmpDir); + assert.ok(result.success, `state json failed: ${result.error}`); + const { progress } = JSON.parse(result.output); + assert.equal(progress.total_phases, 2, `retired project-code phase must be excluded. Got ${progress.total_phases}`); + }); + + test('decimal, multiple, shipped-then-retired, and no-directory retired phases all excluded', () => { + // Retired: 02 (executed → has SUMMARY, then folded), 04 (no work), + // 05.1 (decimal, no directory at all). Live: 01, 03, 06. + tmpDir = seedFromSpecs('bug-1514-multi-', [ + { id: '01', dir: '01-a', shipped: true }, + { id: '02', retired: true, dir: '02-b', shipped: true }, + { id: '03', dir: '03-c', shipped: true }, + { id: '04', retired: true, dir: '04-d' }, + { id: '05.1', retired: true }, + { id: '06', dir: '06-f', shipped: true }, + ]); + const result = runGsdTools(['state', 'json'], tmpDir); + assert.ok(result.success, `state json failed: ${result.error}`); + const { progress } = JSON.parse(result.output); + assert.equal(progress.total_phases, 3, `3 retired of 6 → total 3. Got ${progress.total_phases}`); + assert.equal(progress.completed_phases, 3, `live phases 01/03/06 complete. Got ${progress.completed_phases}`); + assert.equal(progress.percent, 100, `Got ${progress.percent}`); + }); + + test('boundary: every phase retired (k === n) → total_phases 0', () => { + tmpDir = seedFromSpecs('bug-1514-all-', [ + { id: '01', retired: true, dir: '01-a' }, + { id: '02', retired: true, dir: '02-b' }, + { id: '03', retired: true, dir: '03-c' }, + ]); + const result = runGsdTools(['state', 'json'], tmpDir); + assert.ok(result.success, `state json failed: ${result.error}`); + const { progress } = JSON.parse(result.output); + assert.equal(progress.total_phases, 0, `all phases retired → denominator 0. Got ${progress.total_phases}`); + assert.equal(progress.completed_phases, 0, `Got ${progress.completed_phases}`); + }); + + test('strikethrough in a non-checklist/heading line (a goal) does NOT retire that phase', () => { + // Detection is scoped to checklist/heading lines, so a struck GOAL line + // that begins with a phase reference must not retire it. + const tmp = createTempProject('bug-1514-prose-'); + const planning = path.join(tmp, '.planning'); + const roadmapText = [ + '## Milestone v1.0: Prose', + '', + '### Phases', + '- [x] **Phase 01: A** — done', + '- [x] **Phase 02: B** — done', + '- [x] **Phase 03: C** — done', + '', + '### Phase 01: A', '**Goal:** g', + '### Phase 02: B', '**Goal:** ~~Phase 02 was renamed from an earlier plan~~', + '### Phase 03: C', '**Goal:** g', + ].join('\n'); + fs.writeFileSync(path.join(planning, 'ROADMAP.md'), roadmapText, 'utf-8'); + fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8'); + fs.writeFileSync( + path.join(planning, 'STATE.md'), + ['---', 'gsd_state_version: 1.0', 'milestone: v1.0', 'status: executing', '---', '', '# GSD State', '', '## Configuration', 'Current Phase: 3'].join('\n'), + 'utf-8', + ); + for (const d of ['01-a', '02-b', '03-c']) { + const dir = path.join(planning, 'phases', d); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'PLAN.md'), '# Plan\n', 'utf-8'); + fs.writeFileSync(path.join(dir, 'SUMMARY.md'), '# Summary\n', 'utf-8'); + } + tmpDir = tmp; + const result = runGsdTools(['state', 'json'], tmpDir); + assert.ok(result.success, `state json failed: ${result.error}`); + const { progress } = JSON.parse(result.output); + assert.equal(progress.total_phases, 3, `struck prose in a goal line must not retire Phase 02. Got ${progress.total_phases}`); + assert.equal(progress.completed_phases, 3, `Got ${progress.completed_phases}`); + }); +}); + +// ─── Property: the strikethrough parser extracts exactly the struck set ────── + +// extractRetiredPhaseNumbers is the parsing/transformation core of the fix, so +// per RULESET.TESTS.property-based-testing it carries a fast-check property: +// for a roadmap with k of n checklist phases struck, the parser must return +// exactly the canonical keys of those k phases — no more, no fewer — across +// randomized phase counts and numeric/zero-padded/project-code ID forms. This +// underpins the `total_phases === n - k` guarantee the integration tests assert. +describe('bug #1514 — extractRetiredPhaseNumbers property: returns exactly the struck set', () => { + const idForm = (num, form) => + form === 'padded' ? String(num).padStart(2, '0') + : form === 'project' ? `PROJ-${num}` + : String(num); + const keyOf = (num, form) => normalizePhaseName(idForm(num, form)).toUpperCase(); + + test('k-of-n struck phases → exactly k canonical keys, for any n/form', () => { + fc.assert( + fc.property( + // Distinct phase numbers so canonical keys don't collide within a run. + fc.uniqueArray(fc.integer({ min: 1, max: 98 }), { minLength: 1, maxLength: 10 }), + fc.array(fc.boolean(), { minLength: 1, maxLength: 10 }), + fc.constantFrom('plain', 'padded', 'project'), + (nums, flagsRaw, form) => { + const lines = ['## Milestone v1.0: M', '', '### Phases']; + const struck = []; + nums.forEach((num, i) => { + const id = idForm(num, form); + if (flagsRaw[i]) { + lines.push(`- [x] ~~**Phase ${id}: P${num}**~~ — folded; retired`); + struck.push(num); + } else { + lines.push(`- [x] **Phase ${id}: P${num}** — done`); + } + }); + + const got = _extractRetiredPhaseNumbers(lines.join('\n')); + const expected = new Set(struck.map((num) => keyOf(num, form))); + + assert.equal(got.size, expected.size, `size: got ${got.size}, expected ${expected.size}`); + for (const k of expected) assert.ok(got.has(k), `missing struck key ${k}`); + for (const k of got) assert.ok(expected.has(k), `extra (non-struck) key ${k}`); + }, + ), + ); + }); +}); + }); +} diff --git a/tests/verify.test.cjs b/tests/verify.test.cjs index 5f4f7a2b9..712245fee 100644 --- a/tests/verify.test.cjs +++ b/tests/verify.test.cjs @@ -1336,3 +1336,954 @@ describe('bug-967 verify key-links strict file-path contract', () => { }); }); } + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-2446-milestones-drift.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-2446-milestones-drift (consolidation epic #1969 B3 #1972)", () => { +'use strict'; + +// allow-test-rule: source-text-is-the-product (see #2446) +// Reads .md/.json/.yml product files whose deployed text IS what the +// runtime loads — testing text content tests the deployed contract. + +/** + * Tests for gsd-health MILESTONES.md drift detection (#2446). + */ + +const { test, after } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const helpers = require('./helpers.cjs'); + +const { cmdValidateHealth } = require('../gsd-core/bin/lib/verify.cjs'); + +const _dirsToClean = []; +after(() => { for (const d of _dirsToClean) helpers.cleanup(d); }); + +function makeTempProject(files = {}) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2446-')); + _dirsToClean.push(dir); + fs.mkdirSync(path.join(dir, '.planning', 'milestones'), { recursive: true }); + for (const [rel, content] of Object.entries(files)) { + const abs = path.join(dir, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, content, 'utf-8'); + } + return dir; +} + +test('W018: warns when archived snapshot has no MILESTONES.md entry', () => { + const dir = makeTempProject({ + '.planning/PROJECT.md': '# P\n\n## What This Is\n\nX\n\n## Core Value\n\nY\n\n## Requirements\n\nZ\n', + '.planning/ROADMAP.md': '# Roadmap\n', + '.planning/STATE.md': '# State\n', + '.planning/config.json': '{}', + '.planning/milestones/v1.0-ROADMAP.md': '# Milestone v1.0\n', + // No MILESTONES.md entry for v1.0 + }); + + const result = cmdValidateHealth(dir, { repair: false }, false); + + const w018 = result.warnings.find(w => w.code === 'W018'); + assert.ok(w018, 'W018 warning should be emitted'); + assert.ok(w018.message.includes('v1.0'), 'warning should mention v1.0'); + assert.ok(w018.repairable, 'W018 should be marked repairable'); +}); + +test('no W018 when all snapshots have MILESTONES.md entries', () => { + const dir = makeTempProject({ + '.planning/PROJECT.md': '# P\n\n## What This Is\n\nX\n\n## Core Value\n\nY\n\n## Requirements\n\nZ\n', + '.planning/ROADMAP.md': '# Roadmap\n', + '.planning/STATE.md': '# State\n', + '.planning/config.json': '{}', + '.planning/milestones/v1.0-ROADMAP.md': '# Milestone v1.0\n', + '.planning/MILESTONES.md': '# Milestones\n\n## v1.0 My App (Shipped: 2026-01-01)\n\n---\n\n', + }); + + const result = cmdValidateHealth(dir, { repair: false }, false); + + const w018 = result.warnings.find(w => w.code === 'W018'); + assert.strictEqual(w018, undefined, 'no W018 when entries are present'); +}); + +test('no W018 when milestones archive dir is empty', () => { + const dir = makeTempProject({ + '.planning/PROJECT.md': '# P\n\n## What This Is\n\nX\n\n## Core Value\n\nY\n\n## Requirements\n\nZ\n', + '.planning/ROADMAP.md': '# Roadmap\n', + '.planning/STATE.md': '# State\n', + '.planning/config.json': '{}', + // No snapshots in milestones/ + }); + + const result = cmdValidateHealth(dir, { repair: false }, false); + + const w018 = result.warnings.find(w => w.code === 'W018'); + assert.strictEqual(w018, undefined, 'no W018 with empty archive dir'); +}); + +test('--backfill synthesizes missing MILESTONES.md entry from snapshot', () => { + const dir = makeTempProject({ + '.planning/PROJECT.md': '# P\n\n## What This Is\n\nX\n\n## Core Value\n\nY\n\n## Requirements\n\nZ\n', + '.planning/ROADMAP.md': '# Roadmap\n', + '.planning/STATE.md': '# State\n', + '.planning/config.json': '{}', + '.planning/milestones/v1.0-ROADMAP.md': '# Milestone v1.0 First Release\n', + }); + + cmdValidateHealth(dir, { repair: true, backfill: true }, false); + + const milestonesPath = path.join(dir, '.planning', 'MILESTONES.md'); + assert.ok(fs.existsSync(milestonesPath), 'MILESTONES.md should be created'); + const content = fs.readFileSync(milestonesPath, 'utf-8'); + assert.ok(content.includes('## v1.0'), 'backfilled entry should contain v1.0'); + assert.ok(content.includes('Backfilled'), 'should note it was backfilled'); +}); + +test('health.md mentions --backfill flag', () => { + const healthMd = fs.readFileSync( + path.join(__dirname, '../gsd-core/workflows/health.md'), 'utf-8' + ); + assert.ok(healthMd.includes('--backfill'), 'health.md should document --backfill'); + assert.ok(healthMd.includes('W018'), 'health.md should list W018 error code'); + assert.ok(healthMd.includes('backfillMilestones'), 'repair_actions should include backfillMilestones'); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-968-region-scoped-negative-grep.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-968-region-scoped-negative-grep (consolidation epic #1969 B3 #1972)", () => { +// allow-test-rule: source-text-is-the-product #968 +// Enhancement #968: region-scoped negative gate detector + guidance docs. +// Tests the pure function scanFileWideNegativeGateConflict exported from +// verify.cjs, plus CLI integration and doc-contract assertions. + +'use strict'; + +const { test, describe, before, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const os = require('node:os'); +const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs'); + +// Build path to built verify.cjs +const VERIFY_CJS = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'verify.cjs'); + +// Build paths to doc files +const PLANNER_MD = path.join(__dirname, '..', 'agents', 'gsd-planner.md'); +const ANTIPATTERNS_MD = path.join(__dirname, '..', 'gsd-core', 'references', 'planner-antipatterns.md'); +// PLAN_MD_REF removed — was unused (doc-contract cases test PLANNER_MD and ANTIPATTERNS_MD only) + +// ─── Fixture helpers ─────────────────────────────────────────────────────────── + +/** + * Build a minimal two-task plan fixture. + * taskA: file=app/page.py, gateText=the verify/acceptance_criteria block, actionText=action block + * taskB: file=app/page.py (default) or otherFile, action text + * allowlistMarker: optional HTML comment to insert at the top + */ +function makeTwoTaskPlan({ + taskAFile = 'app/page.py', + taskAGate = '! grep -Eq \'await .*refresh\' app/page.py', + taskAAction = 'Refactor the factory to be synchronous.', + taskBFile = 'app/page.py', + taskBAction = 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', + allowlistMarker = '', +} = {}) { + const lines = [ + '---', + 'phase: 01-test', + 'plan: 01', + 'type: execute', + 'wave: 1', + 'depends_on: []', + `files_modified: [${taskAFile}, ${taskBFile}]`, + 'autonomous: true', + 'must_haves:', + ' - AC1', + '---', + '', + '# Test Plan', + '', + ]; + + if (allowlistMarker) { + lines.push(allowlistMarker, ''); + } + + // Task A: the one with the negative gate + lines.push(''); + lines.push('Task A: factory refactor'); + lines.push(`${taskAFile}`); + lines.push(`${taskAAction}`); + lines.push(`${taskAGate}`); + lines.push('Factory is synchronous.'); + lines.push(''); + lines.push(''); + + // Task B: the sibling that requires the construct + lines.push(''); + lines.push('Task B: reindex handler'); + lines.push(`${taskBFile}`); + lines.push(`${taskBAction}`); + lines.push('npm test'); + lines.push('Handler is in place.'); + lines.push(''); + + return lines.join('\n'); +} + +/** + * Build a single-task plan (no sibling). + */ +function makeSingleTaskPlan({ + taskFile = 'app/page.py', + taskGate = '! grep -Eq \'await .*refresh\' app/page.py', + taskAction = 'Refactor the factory to be synchronous.', +} = {}) { + return [ + '---', + 'phase: 01-test', + 'plan: 01', + 'type: execute', + 'wave: 1', + 'depends_on: []', + `files_modified: [${taskFile}]`, + 'autonomous: true', + 'must_haves:', + ' - AC1', + '---', + '', + '# Test Plan', + '', + '', + 'Task A: factory refactor', + `${taskFile}`, + `${taskAction}`, + `${taskGate}`, + 'Factory is synchronous.', + '', + ].join('\n'); +} + +// ─── Group 1: pure-function unit tests ──────────────────────────────────────── + +describe('scanFileWideNegativeGateConflict — pure unit tests', () => { + let scan; + + before(() => { + const verify = require(VERIFY_CJS); + scan = verify.scanFileWideNegativeGateConflict; + assert.ok(typeof scan === 'function', 'scanFileWideNegativeGateConflict must be exported'); + }); + + // Case 1: basic WARN path — Task A bans PAT file-wide, Task B requires it in same file + test('case 1 — file-wide ban + sibling requires → WARN', () => { + const content = makeTwoTaskPlan({ + taskAGate: "! grep -Eq 'await .*refresh' app/page.py", + taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', + }); + const result = scan(content); + assert.ok(Array.isArray(result.warnings), 'must return { warnings: [] }'); + assert.ok( + result.warnings.length >= 1, + `expected at least 1 warning, got: ${JSON.stringify(result.warnings)}`, + ); + assert.ok( + result.warnings[0].includes('Region-scope conflict (#968)'), + `warning must mention Region-scope conflict (#968), got: ${result.warnings[0]}`, + ); + assert.ok( + result.warnings[0].includes('await .*refresh'), + `warning must mention the PAT, got: ${result.warnings[0]}`, + ); + assert.ok( + result.warnings[0].includes('app/page.py'), + `warning must mention the file, got: ${result.warnings[0]}`, + ); + assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true'); + }); + + // Case 2: region-scoped via sed → NO warn + test('case 2 — region-scoped via sed pipe → NO warn', () => { + const content = makeTwoTaskPlan({ + taskAGate: "! sed -n '12,40p' app/page.py | grep -Eq 'await .*refresh'", + taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', + }); + const result = scan(content); + assert.strictEqual( + result.warnings.filter(w => w.includes('#968')).length, + 0, + `sed-piped grep is region-scoped — must not warn, got: ${JSON.stringify(result.warnings)}`, + ); + }); + + // Case 2b: region-scoped via awk → NO warn + test('case 2b — region-scoped via awk pipe → NO warn', () => { + const content = makeTwoTaskPlan({ + taskAGate: "! awk '/^def make_page/,/^def /' app/page.py | grep -Eq 'await .*refresh'", + taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', + }); + const result = scan(content); + assert.strictEqual( + result.warnings.filter(w => w.includes('#968')).length, + 0, + `awk-piped grep is region-scoped — must not warn, got: ${JSON.stringify(result.warnings)}`, + ); + }); + + // Case 3: single task file-wide ban, no sibling → NO warn + test('case 3 — single task, no sibling → NO warn', () => { + const content = makeSingleTaskPlan({ + taskGate: "! grep -Eq 'await .*refresh' app/page.py", + }); + const result = scan(content); + assert.strictEqual( + result.warnings.filter(w => w.includes('#968')).length, + 0, + `single task (no sibling) must not warn, got: ${JSON.stringify(result.warnings)}`, + ); + }); + + // Case 4: sibling requires PAT but lists a different file → NO warn + test('case 4 — sibling lists different file → NO warn', () => { + const content = makeTwoTaskPlan({ + taskAFile: 'app/page.py', + taskAGate: "! grep -Eq 'await .*refresh' app/page.py", + taskBFile: 'app/other.py', + taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', + }); + const result = scan(content); + assert.strictEqual( + result.warnings.filter(w => w.includes('#968')).length, + 0, + `sibling with different file must not warn, got: ${JSON.stringify(result.warnings)}`, + ); + }); + + // Case 5: sibling lists same file but action lacks PAT → NO warn + test('case 5 — sibling lists same file but action lacks PAT → NO warn', () => { + const content = makeTwoTaskPlan({ + taskAGate: "! grep -Eq 'await .*refresh' app/page.py", + taskBAction: 'Add a post-reindex handler that calls bridge.sync() to repopulate state.', + }); + const result = scan(content); + assert.strictEqual( + result.warnings.filter(w => w.includes('#968')).length, + 0, + `sibling with no PAT in action must not warn, got: ${JSON.stringify(result.warnings)}`, + ); + }); + + // Case 6: positive grep (no !) + sibling → NO warn (positive requirement, not a ban) + test('case 6 — positive grep (no !) + sibling → NO warn', () => { + const content = makeTwoTaskPlan({ + taskAGate: "grep -q 'await .*refresh' app/page.py", + taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', + }); + const result = scan(content); + assert.strictEqual( + result.warnings.filter(w => w.includes('#968')).length, + 0, + `positive grep must not warn, got: ${JSON.stringify(result.warnings)}`, + ); + }); + + // Case 7: inverted grep -v with ! + sibling → NO warn + test('case 7 — inverted grep -vq with ! + sibling → NO warn', () => { + const content = makeTwoTaskPlan({ + taskAGate: "! grep -vq 'await .*refresh' app/page.py", + taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', + }); + const result = scan(content); + assert.strictEqual( + result.warnings.filter(w => w.includes('#968')).length, + 0, + `inverted grep (-v) must not warn, got: ${JSON.stringify(result.warnings)}`, + ); + }); + + // Case 8: allowlist marker present → NO warn + test('case 8 — allowlist marker suppresses warn', () => { + const content = makeTwoTaskPlan({ + taskAGate: "! grep -Eq 'await .*refresh' app/page.py", + taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', + allowlistMarker: '', + }); + const result = scan(content); + assert.strictEqual( + result.warnings.filter(w => w.includes('#968')).length, + 0, + `allowlist marker must suppress warn, got: ${JSON.stringify(result.warnings)}`, + ); + }); + + // Case 9: one task both bans and requires same PAT in same file (no second task) → NO warn + test('case 9 — one task bans and requires PAT (no sibling) → NO warn', () => { + const content = makeSingleTaskPlan({ + taskGate: "! grep -Eq 'await .*refresh' app/page.py", + taskAction: 'Refactor to avoid await refresh, but note that bridge.refresh() is used later.', + }); + const result = scan(content); + assert.strictEqual( + result.warnings.filter(w => w.includes('#968')).length, + 0, + `single task (no sibling B) must not warn, got: ${JSON.stringify(result.warnings)}`, + ); + }); + + // Case 10: count form `grep -c 'PAT' FILE == 0` + sibling → WARN + test('case 10 — count form (grep -c PAT FILE == 0) + sibling → WARN', () => { + const content = makeTwoTaskPlan({ + taskAGate: "grep -c 'await .*refresh' app/page.py == 0", + taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', + }); + const result = scan(content); + assert.ok( + result.warnings.filter(w => w.includes('#968')).length >= 1, + `count form (grep -c ... == 0) must warn, got: ${JSON.stringify(result.warnings)}`, + ); + assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true'); + }); + + // Case 11: bracket form `[ $(grep -c PAT FILE) -eq 0 ]` + sibling → WARN + test('case 11 — bracket form ([ $(grep -c PAT FILE) -eq 0 ]) + sibling → WARN', () => { + const content = makeTwoTaskPlan({ + taskAGate: "[ $(grep -c 'await .*refresh' app/page.py) -eq 0 ]", + taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', + }); + const result = scan(content); + assert.ok( + result.warnings.filter(w => w.includes('#968')).length >= 1, + `bracket form must warn, got: ${JSON.stringify(result.warnings)}`, + ); + assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true'); + }); + + // Case 12: CRLF variant of case 1 → WARN + test('case 12 — CRLF line endings → WARN', () => { + const content = makeTwoTaskPlan({ + taskAGate: "! grep -Eq 'await .*refresh' app/page.py", + taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', + }); + const crlfContent = content.split('\n').join('\r\n'); + const result = scan(crlfContent); + assert.ok( + result.warnings.filter(w => w.includes('#968')).length >= 1, + `CRLF content must still warn, got: ${JSON.stringify(result.warnings)}`, + ); + assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true'); + }); + + // Case 13: backslash line-continuation variant → WARN + test('case 13 — backslash line continuation → WARN', () => { + // Build manually to control exact line continuation + const lines = [ + '---', + 'phase: 01-test', + 'plan: 01', + 'type: execute', + 'wave: 1', + 'depends_on: []', + 'files_modified: [app/page.py]', + 'autonomous: true', + 'must_haves:', + ' - AC1', + '---', + '', + '', + 'Task A: factory refactor', + 'app/page.py', + 'Refactor the factory to be synchronous.', + // Gate split across lines with backslash continuation + "! grep -Eq 'await .*refresh' \\\napp/page.py", + 'Factory is synchronous.', + '', + '', + '', + 'Task B: reindex handler', + 'app/page.py', + 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', + 'npm test', + 'Handler is in place.', + '', + ].join('\n'); + const result = scan(lines); + assert.ok( + result.warnings.filter(w => w.includes('#968')).length >= 1, + `backslash continuation must still warn, got: ${JSON.stringify(result.warnings)}`, + ); + assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true'); + }); + + // Case 14: mixed line with positive gate AND a negative gate, sibling → WARN (on the negative only) + test('case 14 — mixed positive+negative on one segment + sibling → WARN for negative', () => { + const content = makeTwoTaskPlan({ + taskAGate: "grep -c 'X' app/page.py == 1 && ! grep -Eq 'await .*refresh' app/page.py", + taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', + }); + const result = scan(content); + assert.ok( + result.warnings.filter(w => w.includes('#968')).length >= 1, + `mixed line with negative gate + sibling must warn, got: ${JSON.stringify(result.warnings)}`, + ); + assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true'); + }); + + // Case 15: glob file arg `app/*.py` → NO warn (unresolvable path) + test('case 15 — glob file arg → NO warn (unresolvable)', () => { + const content = makeTwoTaskPlan({ + taskAGate: "! grep -Eq 'await .*refresh' app/*.py", + taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', + }); + const result = scan(content); + assert.strictEqual( + result.warnings.filter(w => w.includes('#968')).length, + 0, + `glob file arg must not warn (unresolvable), got: ${JSON.stringify(result.warnings)}`, + ); + }); + + // Case 16: invalid-regex PAT literal fallback → WARN, no exception + test('case 16 — invalid-regex PAT → literal fallback, WARN, no exception', () => { + // "await (refresh" — unbalanced paren, invalid regex + const content = makeTwoTaskPlan({ + taskAGate: "! grep -Eq 'await (refresh' app/page.py", + taskBAction: 'The handler calls await (refresh on bridge to repopulate state.', + }); + let result; + assert.doesNotThrow(() => { + result = scan(content); + }, 'scan must not throw on invalid regex PAT'); + assert.ok( + result.warnings.filter(w => w.includes('#968')).length >= 1, + `invalid-regex PAT with literal match must warn, got: ${JSON.stringify(result.warnings)}`, + ); + }); + + // Case 17: ReDoS-ish PAT (catastrophic backtracking) → no hang, no false warn. + // The sibling action is 5000 'a's — classic ReDoS trigger if we call new RegExp('(a+)+$'). + // Proof-of-no-hang: the test runner's own timeout catches it; a hanging test fails here. + // No timing assertion (flaky) — the linear patternRequiredIn implementation is microsecond-fast. + test('case 17 — catastrophic ReDoS pattern is instant, no hang, no false warn', () => { + const longAs = 'a'.repeat(5000); + const content = makeTwoTaskPlan({ + taskAGate: "! grep -Eq '(a+)+$' app/page.py", + taskBAction: `Reindex handler that processes ${longAs} records and calls bridge.refresh().`, + }); + let result; + assert.doesNotThrow(() => { + result = scan(content); + }, 'scan must not throw on ReDoS-ish PAT'); + // The literal '(a+)+$' is not present in the action text as a substring → no warn. + // (If new RegExp were used, this test would hang before reaching this assertion.) + assert.strictEqual( + result.warnings.filter(w => w.includes('#968')).length, + 0, + `catastrophic PAT '(a+)+$' not literally in action — must not warn, got: ${JSON.stringify(result.warnings)}`, + ); + assert.ok(Array.isArray(result.warnings), 'valid result shape'); + }); + + // Case 23 (mutation-catching): cat producer = file-wide → WARN; sed producer = region-scoped → NO warn + test('case 23a — cat pipe: ! cat app/page.py | grep -Eq PAT + sibling → WARN (file-wide via cat)', () => { + const content = makeTwoTaskPlan({ + taskAGate: "! cat app/page.py | grep -Eq 'await .*refresh'", + taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', + }); + const result = scan(content); + assert.ok( + result.warnings.filter(w => w.includes('#968')).length >= 1, + `cat-piped grep is file-wide — must warn, got: ${JSON.stringify(result.warnings)}`, + ); + assert.ok(result.valid !== false, 'valid must remain true even when #968 warns'); + }); + + test('case 23b — sed pipe: ! sed -n "12,40p" app/page.py | grep -Eq PAT + sibling → NO warn (region-scoped)', () => { + const content = makeTwoTaskPlan({ + taskAGate: "! sed -n '12,40p' app/page.py | grep -Eq 'await .*refresh'", + taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', + }); + const result = scan(content); + assert.strictEqual( + result.warnings.filter(w => w.includes('#968')).length, + 0, + `sed-piped grep is region-scoped — must NOT warn, got: ${JSON.stringify(result.warnings)}`, + ); + }); + + // Case 24: awk region → NO warn + test('case 24 — awk region pipe: ! awk \'/^def make_page/,/^def /\' app/page.py | grep -Eq PAT + sibling → NO warn', () => { + const content = makeTwoTaskPlan({ + taskAGate: "! awk '/^def make_page/,/^def /' app/page.py | grep -Eq 'await .*refresh'", + taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', + }); + const result = scan(content); + assert.strictEqual( + result.warnings.filter(w => w.includes('#968')).length, + 0, + `awk-piped grep is region-scoped — must NOT warn, got: ${JSON.stringify(result.warnings)}`, + ); + }); + + // Case 25: basename non-over-match — different dirs, same basename → NO warn + test('case 25 — basename non-over-match: different dirs same filename → NO warn', () => { + // Task A bans on apps/web/config.py; Task B lists apps/admin/config.py + // Same basename "config.py" but different dirs → must NOT warn + const content = makeTwoTaskPlan({ + taskAFile: 'apps/web/config.py', + taskAGate: "! grep -Eq 'await .*refresh' apps/web/config.py", + taskBFile: 'apps/admin/config.py', + taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', + }); + const result = scan(content); + assert.strictEqual( + result.warnings.filter(w => w.includes('#968')).length, + 0, + `different dirs (apps/web/config.py vs apps/admin/config.py) — same basename but must NOT warn, got: ${JSON.stringify(result.warnings)}`, + ); + }); + + // Case 26: extensionless known file (Dockerfile) recognized via knownFiles → WARN + test('case 26 — extensionless known file (Dockerfile) via knownFiles → WARN', () => { + // Task A has ! grep -Eq 'FROM scratch' Dockerfile + // Dockerfile has no extension, so looksLikePath would miss it — but knownFiles should catch it + // Task B lists Dockerfile in and action requires 'FROM scratch' + const content = makeTwoTaskPlan({ + taskAFile: 'Dockerfile', + taskAGate: "! grep -Eq 'FROM scratch' Dockerfile", + taskBFile: 'Dockerfile', + taskBAction: 'Update the image base: FROM scratch ensures minimal surface area.', + }); + const result = scan(content); + assert.ok( + result.warnings.filter(w => w.includes('#968')).length >= 1, + `Dockerfile (extensionless, known via ) should be recognized — must warn, got: ${JSON.stringify(result.warnings)}`, + ); + assert.ok(result.valid !== false, 'valid must remain true'); + }); + + // Case 27: wildcard semantic match — patternRequiredIn handles .* correctly + test('case 27 — wildcard semantic match: "await .*refresh" (gate) warns when action has "await bridge.refresh()"', () => { + const content = makeTwoTaskPlan({ + taskAGate: "! grep -Eq 'await .*refresh' app/page.py", + taskBAction: 'Add handler that calls await bridge.refresh() to repopulate state.', + }); + const result = scan(content); + assert.ok( + result.warnings.filter(w => w.includes('#968')).length >= 1, + `patternRequiredIn must match "await .*refresh" against "await bridge.refresh()" — must warn, got: ${JSON.stringify(result.warnings)}`, + ); + assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true'); + }); + + // Case 4b: same-file positive control — sibling lists the SAME banned file + requires PAT → WARN + // Paired with case 4: proves the no-warn in case 4 is due to the file mismatch, not a dead detector. + test('case 4b — same-file positive control: sibling lists same file → WARN (proves case 4 no-warn is file-mismatch)', () => { + const content = makeTwoTaskPlan({ + taskAFile: 'app/page.py', + taskAGate: "! grep -Eq 'await .*refresh' app/page.py", + taskBFile: 'app/page.py', + taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', + }); + const result = scan(content); + assert.ok( + result.warnings.filter(w => w.includes('#968')).length >= 1, + `same-file sibling must warn — proves case 4's no-warn is due to file mismatch, got: ${JSON.stringify(result.warnings)}`, + ); + assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true'); + }); + + // Case 7b: non-inverted positive control — without -v the ban IS detected → WARN + // Paired with case 7: proves the -v skip is what suppresses case 7. + test('case 7b — non-inverted positive control: ! grep -q (no -v) + sibling → WARN (proves case 7 no-warn is -v skip)', () => { + const content = makeTwoTaskPlan({ + taskAGate: "! grep -q 'await .*refresh' app/page.py", + taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', + }); + const result = scan(content); + assert.ok( + result.warnings.filter(w => w.includes('#968')).length >= 1, + `non-inverted ! grep -q must warn — proves the -v flag is what suppresses case 7, got: ${JSON.stringify(result.warnings)}`, + ); + assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true'); + }); + + // Case 25b: basename-fallback positive — bare unqualified filename matches sibling's qualified path → WARN + // Paired with case 25: proves the bare-name basename fallback at src ~line 525 actually fires. + // Case 25 only proves qualified paths don't over-match; this proves the bare fallback does fire. + test('case 25b — basename-fallback positive: bare gate file matches sibling qualified path → WARN (proves basename fallback fires)', () => { + // Task A gate uses bare "config.py" (no directory prefix — unqualified). + // Task B lists "apps/admin/config.py" (qualified). basename("apps/admin/config.py") === "config.py". + // The basename fallback (line 525) should match → WARN. + const content = makeTwoTaskPlan({ + taskAFile: 'config.py', + taskAGate: "! grep -Eq 'await .*refresh' config.py", + taskBFile: 'apps/admin/config.py', + taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', + }); + const result = scan(content); + assert.ok( + result.warnings.filter(w => w.includes('#968')).length >= 1, + `bare gate file "config.py" must match sibling "apps/admin/config.py" via basename fallback — must warn, got: ${JSON.stringify(result.warnings)}`, + ); + assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true'); + }); + + // Case 28: anchored pattern warns after ^ strip — proves anchor stripping works + // Gate: ! grep -Eq '^FROM scratch' Dockerfile + // Sibling B lists Dockerfile, action requires 'FROM scratch' (no anchor in prose). + // Without anchor stripping, "^FROM scratch" would be treated as containing metacharacters + // and fall back to literal-substring: "^FROM scratch" not in B's prose → no warn. + // With anchor stripping, "FROM scratch" is the effective literal → found in B's prose → WARN. + test('case 28 — anchored pattern warns: ! grep -Eq \'^FROM scratch\' Dockerfile + sibling → WARN (proves ^ strip)', () => { + const content = makeTwoTaskPlan({ + taskAFile: 'Dockerfile', + taskAGate: "! grep -Eq '^FROM scratch' Dockerfile", + taskBFile: 'Dockerfile', + taskBAction: 'Update the image base: FROM scratch ensures minimal surface area.', + }); + const result = scan(content); + assert.ok( + result.warnings.filter(w => w.includes('#968')).length >= 1, + `anchored pattern "^FROM scratch" must warn after ^ is stripped — "FROM scratch" is in sibling action, got: ${JSON.stringify(result.warnings)}`, + ); + assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true'); + }); + + // Case 29: alternation falls back conservatively — documents the known limitation. + // Gate: ! grep -Eq 'debug|trace' src/logger.ts + // Sibling B lists src/logger.ts, action says "remove debug calls" (contains "debug" but NOT "debug|trace"). + // patternRequiredIn sees unhandled `|` in joined frags → literal-substring fallback on raw pattern. + // "debug|trace" is NOT literally in B's prose → conservative NO warn. + // This is intentional: false-negative is the safe direction for a warn-only advisory. + test('case 29 — alternation conservative fallback: "debug|trace" → NO warn (documents alternation limitation)', () => { + // NOTE: This is intended conservative behavior, not a bug. + // patternRequiredIn falls back to literal-substring for patterns containing `|` (alternation), + // because safely expanding alternation without new RegExp would require a mini-parser. + // The literal "debug|trace" is not present verbatim in the action, so no warn fires. + // A planner who writes `debug|trace` gets no advisory — acceptable, since a false-negative + // is always safer than a false-positive for a warn-only gate. + const content = makeTwoTaskPlan({ + taskAFile: 'src/logger.ts', + taskAGate: "! grep -Eq 'debug|trace' src/logger.ts", + taskBFile: 'src/logger.ts', + taskBAction: 'Remove debug calls from the logger module to reduce noise.', + }); + const result = scan(content); + assert.strictEqual( + result.warnings.filter(w => w.includes('#968')).length, + 0, + `alternation pattern "debug|trace" must conservatively NOT warn — literal "debug|trace" not in action, got: ${JSON.stringify(result.warnings)}`, + ); + }); + + // Case 18: empty content → no crash, no #968 warn + test('case 18 — empty content → no crash', () => { + let result; + assert.doesNotThrow(() => { + result = scan(''); + }); + assert.ok(Array.isArray(result.warnings), 'must return { warnings: [] }'); + assert.strictEqual( + result.warnings.filter(w => w.includes('#968')).length, + 0, + 'empty content must produce no #968 warn', + ); + }); + + // Case 18b: no-task plan → no crash + test('case 18b — no-task plan → no crash', () => { + const content = [ + '---', + 'phase: 01-test', + 'plan: 01', + 'type: execute', + 'wave: 1', + 'depends_on: []', + 'files_modified: []', + 'autonomous: true', + 'must_haves:', + ' - AC1', + '---', + '', + '# No tasks here', + ].join('\n'); + let result; + assert.doesNotThrow(() => { + result = scan(content); + }); + assert.strictEqual(result.warnings.filter(w => w.includes('#968')).length, 0); + }); +}); + +// ─── Group 2: end-to-end via runGsdTools ────────────────────────────────────── + +describe('scanFileWideNegativeGateConflict — end-to-end via verify plan-structure', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + // Case 19: integration — valid stays true despite warning (warn-only) + test('case 19 — integration: valid===true despite #968 warning', () => { + const planContent = makeTwoTaskPlan({ + taskAGate: "! grep -Eq 'await .*refresh' app/page.py", + taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', + }); + const planDir = path.join(tmpDir, '.planning', 'phases', '01-test'); + fs.mkdirSync(planDir, { recursive: true }); + fs.writeFileSync(path.join(planDir, '01-01-PLAN.md'), planContent); + + const result = runGsdTools( + 'verify plan-structure .planning/phases/01-test/01-01-PLAN.md', + tmpDir, + ); + const parsed = JSON.parse(result.output); + assert.strictEqual( + parsed.valid, + true, + `#968 is warn-only: valid must be true, got: ${JSON.stringify(parsed)}`, + ); + assert.ok( + parsed.warnings.some(w => w.includes('#968')), + `must have a #968 warning, got: ${JSON.stringify(parsed.warnings)}`, + ); + }); +}); + +// ─── Group 3: doc-contract ──────────────────────────────────────────────────── + +describe('doc-contract: guidance prose is in place', () => { + // Case 20: gsd-planner.md has the new guidance + test('case 20 — gsd-planner.md contains Region-scoped negative gates + reference', () => { + const content = fs.readFileSync(PLANNER_MD, 'utf8'); + assert.ok( + content.includes('Region-scoped negative gates'), + 'gsd-planner.md must include "Region-scoped negative gates"', + ); + assert.ok( + content.includes('planner-antipatterns.md'), + 'gsd-planner.md must reference planner-antipatterns.md', + ); + }); + + // Case 21: planner-antipatterns.md has the new section + test('case 21 — planner-antipatterns.md has ## Region-Scoped Negative Gates + examples', () => { + const content = fs.readFileSync(ANTIPATTERNS_MD, 'utf8'); + assert.ok( + content.includes('## Region-Scoped Negative Gates'), + 'planner-antipatterns.md must include "## Region-Scoped Negative Gates"', + ); + assert.ok( + content.includes('await .*refresh'), + 'planner-antipatterns.md must include the worked example pattern "await .*refresh"', + ); + // Verify sed or awk region example is present + const hasSedOrAwk = content.includes('sed -n') || content.includes('awk '); + assert.ok( + hasSedOrAwk, + 'planner-antipatterns.md must include sed-n or awk region example', + ); + }); +}); + +// ─── Group 4: AC3 executable proof ─────────────────────────────────────────── + +describe('AC3: executable proof — file-wide ban vs region-scoped simultaneously satisfiable', () => { + test('case 22 — grep/sed proof: both gates simultaneously satisfiable', () => { + // Check if grep and sed are available + const grepAvail = spawnSync('grep', ['--version']).status === 0; + const sedAvail = spawnSync('sed', ['--version']).status === 0 || + spawnSync('sed', ['-n', '1p', '/dev/null']).status === 0; + + if (!grepAvail || !sedAvail) { + // Skip gracefully if tools are unavailable + return; + } + + // Write a temp Python file with: + // def make_page(): — no await refresh + // async def reindex_handler(): — awaits bridge.refresh() + const tmpFile = path.join(os.tmpdir(), `gsd-968-proof-${process.pid}.py`); + const pyContent = [ + 'def make_page():', + ' """Synchronous factory — must not block on a refresh."""', + ' return {"title": "My Page"}', + '', + '', + 'async def reindex_handler():', + ' """Post-reindex callback — must await bridge.refresh() to repopulate state."""', + ' await bridge.refresh()', + ' return True', + ].join('\n'); + fs.writeFileSync(tmpFile, pyContent); + + try { + // (a) File-wide: grep -Eq 'await .*refresh' — should EXIT 0 (pattern found) + // This means a file-wide ban (! grep -Eq ...) WOULD FAIL + const fileWide = spawnSync('grep', ['-Eq', 'await .*refresh', tmpFile]); + assert.strictEqual( + fileWide.status, + 0, + 'grep file-wide should find the pattern (exits 0) — proving the file-wide ban would fail', + ); + + // (b) Region-scoped (make_page only): sed extracts lines 1-3, piped to grep → pattern NOT found + // The factory region is clean: ban PASSES + const makePageLines = spawnSync('sed', ['-n', '1,3p', tmpFile]); + assert.strictEqual(makePageLines.status, 0, 'sed should succeed'); + const makePageRegion = makePageLines.stdout.toString(); + + // Write to a temp file and grep it + const regionFile = path.join(os.tmpdir(), `gsd-968-region-${process.pid}.py`); + fs.writeFileSync(regionFile, makePageRegion); + try { + const regionBan = spawnSync('grep', ['-Eq', 'await .*refresh', regionFile]); + assert.strictEqual( + regionBan.status, + 1, + 'grep in make_page region should NOT find pattern (exits 1) — ban PASSES in factory region', + ); + + // (c) Region-scoped (reindex_handler): grep should FIND the pattern → requirement met + const reindexLines = spawnSync('sed', ['-n', '6,9p', tmpFile]); + const reindexRegion = reindexLines.stdout.toString(); + const reindexFile = path.join(os.tmpdir(), `gsd-968-reindex-${process.pid}.py`); + fs.writeFileSync(reindexFile, reindexRegion); + try { + const reindexCheck = spawnSync('grep', ['-Eq', 'await .*refresh', reindexFile]); + assert.strictEqual( + reindexCheck.status, + 0, + 'grep in reindex_handler region MUST find pattern (exits 0) — requirement met', + ); + } finally { + try { fs.unlinkSync(reindexFile); } catch { /* ignore */ } + } + } finally { + try { fs.unlinkSync(regionFile); } catch { /* ignore */ } + } + } finally { + try { fs.unlinkSync(tmpFile); } catch { /* ignore */ } + } + }); +}); + }); +} diff --git a/tests/worktree-safety.test.cjs b/tests/worktree-safety.test.cjs index fad859975..a6be84f70 100644 --- a/tests/worktree-safety.test.cjs +++ b/tests/worktree-safety.test.cjs @@ -1756,3 +1756,694 @@ describe('worktree-safety: pruneOrphanedWorktrees behaviour', () => { assert.deepStrictEqual(result, []); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3707-locked-worktree-cleanup.test.cjs — consolidation epic #1969 (B3 #1972) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3707-locked-worktree-cleanup (consolidation epic #1969 B3 #1972)", () => { +// allow-test-rule: source-text-is-the-product (see #3707) +// Real-filesystem tests for the two failure modes pinned in #3707: +// 1. executeWorktreeWaveCleanupPlan must unlock-then-retry when a worktree is locked. +// 2. reapOrphanWorktrees must reap dead-pid+merged entries and skip live / unmerged / fresh-mtime entries. +// 3. quick.md and execute-phase.md must wire gsd-sdk query worktree.reap-orphans at startup. + +'use strict'; + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { execFileSync, spawnSync } = require('node:child_process'); +const { cleanup } = require('./helpers.cjs'); + +const { + executeWorktreeWaveCleanupPlan, + reapOrphanWorktrees, +} = require('../gsd-core/bin/lib/worktree-safety.cjs'); + +// ─── Fixed timestamps for deterministic stale-lock boundary ────────────────── +// +// ADR-456 clock-seam mandate: tests must not read the live clock to compute +// fixture mtimes. The SUT compares `Date.now() - lockMtime.getTime()` against +// REAP_MTIME_GUARD_MS (5 minutes). Because `reapOrphanWorktrees` accepts a +// `deps.mtimeSafe` injection, we can supply fixed Date objects that sit +// unconditionally on the "stale" or "fresh" side of the boundary regardless of +// when the test runs, without touching the real filesystem mtime at all. +// +// STALE_MTIME → Unix epoch (1970-01-01T00:00:00Z). At any point in time +// after that epoch `Date.now() - 0` is orders of magnitude +// larger than any staleness threshold. +// +// FRESH_MTIME → Far-future sentinel (year 9999 + large offset). +// `Date.now() - FRESH_MTIME.getTime()` is always negative, +// which is always < REAP_MTIME_GUARD_MS. +// +// Tests that need stale behaviour pass `{ mtimeSafe: () => STALE_MTIME }` in +// deps. Tests that need fresh behaviour pass `{ mtimeSafe: () => FRESH_MTIME }`. +// No `fs.utimesSync` calls are needed and no live `Date.now()` reads appear in +// fixture setup. + +/** Always older than any staleness threshold. */ +const STALE_MTIME = new Date(0); // 1970-01-01T00:00:00.000Z + +/** Always newer than the current time, so always treated as "fresh". */ +const FRESH_MTIME = new Date(8640000000000000); // max safe JS Date (year ~275760) + +// ─── PID helpers ────────────────────────────────────────────────────────────── + +/** + * Return a PID that is guaranteed to be dead. + * Spawns a short-lived child, captures its PID, waits for it to exit, then + * returns that PID. This is cross-platform and not subject to pid_max races + * (unlike a hardcoded high number such as 999999). + */ +function deadPid() { + // Use the shortest possible no-op: `node -e ""` on all platforms. + const nodeExe = process.execPath; + const result = spawnSync(nodeExe, ['-e', ''], { stdio: 'ignore' }); + if (result.pid == null || result.status === null) { + // Fallback: use a PID above the system max — 2^31-1 always exceeds any + // real OS limit (Linux max: 4194304, macOS max: 99998, Windows: variable). + return 2147483647; + } + return result.pid; +} + +// ─── Git repo helpers ───────────────────────────────────────────────────────── + +function canonicalPath(p) { + try { return fs.realpathSync.native(path.resolve(p)); } catch { return path.resolve(p); } +} + +/** + * Return a canonical (long-form) path for os.tmpdir(). + * On Windows CI, os.tmpdir() often contains 8.3 short-name components + * (e.g. RUNNER~1 instead of runneradmin). When 8.3 names are disabled + * (common in modern CI environments), those short paths are not resolvable + * and fs.realpathSync.native fails with ENOENT. Pre-resolving the base + * ensures every path created under it uses the same long-form representation + * that git stores when given absolute paths. + */ +function resolvedTmpDir() { + try { return fs.realpathSync.native(os.tmpdir()); } catch { return os.tmpdir(); } +} + +function git(args, cwd) { + return execFileSync('git', args, { cwd, stdio: 'pipe', encoding: 'utf8' }); +} + +function initRepo(dir) { + fs.mkdirSync(dir, { recursive: true }); + git(['init'], dir); + git(['config', 'user.email', 'test@test.com'], dir); + git(['config', 'user.name', 'Test'], dir); + git(['config', 'commit.gpgsign', 'false'], dir); + fs.writeFileSync(path.join(dir, 'README.md'), '# Test\n'); + git(['add', '-A'], dir); + git(['commit', '-m', 'initial commit'], dir); + try { git(['branch', '-m', 'master', 'main'], dir); } catch { /* already main */ } +} + +function addWorktree(repoDir, wtDir, branchName) { + git(['worktree', 'add', wtDir, '-b', branchName], repoDir); +} + +function commitInWorktree(wtDir, filename) { + const fname = filename || 'work.txt'; + fs.writeFileSync(path.join(wtDir, fname), 'content\n'); + git(['add', '-A'], wtDir); + git(['commit', '-m', `work in ${path.basename(wtDir)}`], wtDir); +} + +function mergeIntoMain(repoDir, branchName) { + git(['merge', branchName, '--no-ff', '-m', `merge ${branchName}`], repoDir); +} + +function worktreeMeta(repoDir, wtDir) { + // Return the .git/worktrees// directory for a given linked worktree + const worktrees = git(['worktree', 'list', '--porcelain'], repoDir); + const canonical = canonicalPath(wtDir); + // Normalize CRLF → LF before splitting (git on Windows may emit CRLF). + const normalized = worktrees.replace(/\r\n/g, '\n'); + const blocks = normalized.split('\n\n').filter(Boolean); + for (const block of blocks) { + const lines = block.split('\n'); + const wtLine = lines.find((l) => l.startsWith('worktree ')); + if (!wtLine) continue; + const wtPath = wtLine.slice('worktree '.length).trim(); + if (canonicalPath(wtPath) !== canonical) continue; + const gitCommonDir = git(['rev-parse', '--git-common-dir'], repoDir).trim(); + const worktreesDir = path.join(path.resolve(repoDir, gitCommonDir), 'worktrees'); + if (!fs.existsSync(worktreesDir)) continue; + for (const entry of fs.readdirSync(worktreesDir)) { + const gitdirFile = path.join(worktreesDir, entry, 'gitdir'); + if (!fs.existsSync(gitdirFile)) continue; + const gitdirContent = fs.readFileSync(gitdirFile, 'utf8').trim(); + const resolvedWtRoot = path.resolve(worktreesDir, entry, gitdirContent).replace(/[/\\]\.git$/, ''); + if (canonicalPath(resolvedWtRoot) === canonical) { + return path.join(worktreesDir, entry); + } + } + } + throw new Error(`Cannot find .git/worktrees/ for worktree at ${wtDir}`); +} + +function listedWorktreePaths(repoDir) { + const out = git(['worktree', 'list', '--porcelain'], repoDir); + return new Set( + out.split('\n').filter((l) => l.startsWith('worktree ')).map((l) => canonicalPath(l.slice('worktree '.length).trim())) + ); +} + +// ─── Suite 1: executeWorktreeWaveCleanupPlan — unlock-and-retry ─────────────── + +describe('bug-3707: executeWorktreeWaveCleanupPlan unlocks and retries on locked worktree', () => { + let tmpBase; + + beforeEach(() => { + tmpBase = fs.mkdtempSync(path.join(resolvedTmpDir(), 'gsd-3707-cleanup-')); + }); + + afterEach(() => { + cleanup(tmpBase); + }); + + test('removes a locked worktree after unlock-retry (real-fs)', () => { + const repoDir = path.join(tmpBase, 'repo'); + const wtDir = path.join(tmpBase, 'wt-locked'); + const branchName = 'worktree-agent-test1'; + + initRepo(repoDir); + addWorktree(repoDir, wtDir, branchName); + commitInWorktree(wtDir); + mergeIntoMain(repoDir, branchName); + + // Simulate Claude Code's lock: write a .git/worktrees//locked file + const metaDir = worktreeMeta(repoDir, wtDir); + const lockedFile = path.join(metaDir, 'locked'); + fs.writeFileSync(lockedFile, 'Locked by claude-code agent-test1'); + + assert.ok(fs.existsSync(lockedFile), 'lock file should exist before test'); + + const baseCommit = git(['merge-base', 'HEAD', branchName], repoDir).trim(); + + const plan = { + ok: true, + repoRoot: repoDir, + action: 'cleanup_wave', + discovery: 'manifest', + entries: [{ + agent_id: 'test1', + worktree_path: wtDir, + branch: branchName, + expected_base: baseCommit, + }], + }; + + const result = executeWorktreeWaveCleanupPlan(plan); + + assert.equal(result.ok, true, `cleanup should succeed, got: ${JSON.stringify(result)}`); + assert.equal(result.entries[0].status, 'merged_removed'); + assert.ok(!fs.existsSync(wtDir), 'worktree directory should be gone after cleanup'); + assert.ok(!listedWorktreePaths(repoDir).has(canonicalPath(wtDir)), 'git worktree list should not include removed worktree'); + }); + + test('cleanup succeeds without a lock file present (no regression)', () => { + const repoDir = path.join(tmpBase, 'repo2'); + const wtDir = path.join(tmpBase, 'wt-unlocked'); + const branchName = 'worktree-agent-test2'; + + initRepo(repoDir); + addWorktree(repoDir, wtDir, branchName); + commitInWorktree(wtDir, 'unlocked.txt'); + mergeIntoMain(repoDir, branchName); + + const baseCommit = git(['merge-base', 'HEAD', branchName], repoDir).trim(); + + const plan = { + ok: true, + repoRoot: repoDir, + action: 'cleanup_wave', + discovery: 'manifest', + entries: [{ + agent_id: 'test2', + worktree_path: wtDir, + branch: branchName, + expected_base: baseCommit, + }], + }; + + const result = executeWorktreeWaveCleanupPlan(plan); + + assert.equal(result.ok, true, `unlocked cleanup should succeed: ${JSON.stringify(result)}`); + assert.equal(result.entries[0].status, 'merged_removed'); + assert.ok(!fs.existsSync(wtDir), 'worktree directory should be gone'); + }); +}); + +// ─── Suite 2: reapOrphanWorktrees ───────────────────────────────────────────── + +describe('bug-3707: reapOrphanWorktrees', () => { + let tmpBase; + + beforeEach(() => { + tmpBase = fs.mkdtempSync(path.join(resolvedTmpDir(), 'gsd-3707-reap-')); + }); + + afterEach(() => { + cleanup(tmpBase); + }); + + // ── Dead PID + merged branch → reap ──────────────────────────────────────── + test('reaps a worktree whose pid is dead and branch is merged into main', () => { + const repoDir = path.join(tmpBase, 'repo'); + const wtDir = path.join(tmpBase, 'wt-dead-merged'); + const branchName = 'worktree-agent-dead-merged'; + + initRepo(repoDir); + addWorktree(repoDir, wtDir, branchName); + commitInWorktree(wtDir); + mergeIntoMain(repoDir, branchName); + + // Write a lock file with a definitely-dead PID. Use the deadPid() helper + // which spawns and reaps a real child process — avoids pid_max flakiness + // on Linux systems where 999999 could be a live PID. + const metaDir = worktreeMeta(repoDir, wtDir); + const lockedFile = path.join(metaDir, 'locked'); + fs.writeFileSync(lockedFile, String(deadPid())); + + // Inject a fixed stale mtime (STALE_MTIME = Unix epoch) so the staleness + // check is deterministic and does not depend on the real clock or utimesSync. + // STALE_MTIME is always older than REAP_MTIME_GUARD_MS (5 min) regardless + // of when this test runs. No fs.utimesSync call is needed. + + // Pre-compute canonical path BEFORE reaping — the directory will be gone + // afterward, so fs.realpathSync.native will fail and canonicalPath falls + // back to path.resolve (non-symlink-resolved). On macOS CI, git internally + // resolves /var/folders → /private/var/folders when writing the gitdir file, + // so r.path uses the real path while wtDir uses the symlink form. Computing + // canonical before removal ensures we compare the resolved forms. + const wtDirCanonical = canonicalPath(wtDir); + + const result = reapOrphanWorktrees(repoDir, { mtimeSafe: () => STALE_MTIME }); + + assert.ok(Array.isArray(result), 'reapOrphanWorktrees should return an array'); + const reaped = result.find((r) => canonicalPath(r.path) === wtDirCanonical); + assert.ok(reaped, `worktree ${wtDir} should appear in reaped list`); + assert.equal(reaped.status, 'reaped'); + assert.ok(!fs.existsSync(wtDir), 'worktree directory should be removed'); + assert.ok(!listedWorktreePaths(repoDir).has(wtDirCanonical), 'git worktree list should not show reaped worktree'); + }); + + // ── Live PID → skip ──────────────────────────────────────────────────────── + test('skips a worktree whose pid is alive', () => { + const repoDir = path.join(tmpBase, 'repo2'); + const wtDir = path.join(tmpBase, 'wt-live-pid'); + const branchName = 'worktree-agent-live-pid'; + + initRepo(repoDir); + addWorktree(repoDir, wtDir, branchName); + commitInWorktree(wtDir); + mergeIntoMain(repoDir, branchName); + + // Write current process PID as the lock owner + const metaDir = worktreeMeta(repoDir, wtDir); + const lockedFile = path.join(metaDir, 'locked'); + fs.writeFileSync(lockedFile, String(process.pid)); + + // Inject STALE_MTIME so the staleness guard passes deterministically, + // ensuring the live-PID check is the only reason the entry is skipped. + const result = reapOrphanWorktrees(repoDir, { mtimeSafe: () => STALE_MTIME }); + + const skipped = result.find((r) => canonicalPath(r.path) === canonicalPath(wtDir)); + if (skipped) { + assert.notEqual(skipped.status, 'reaped', 'live-pid worktree must not be reaped'); + } + assert.ok(fs.existsSync(wtDir), 'worktree directory must still exist for live-pid worktree'); + }); + + // ── Dead PID + unmerged branch → skip (data loss guard) ──────────────────── + test('skips a worktree whose branch has unmerged commits even with dead pid', () => { + const repoDir = path.join(tmpBase, 'repo3'); + const wtDir = path.join(tmpBase, 'wt-unmerged'); + const branchName = 'worktree-agent-unmerged'; + + initRepo(repoDir); + addWorktree(repoDir, wtDir, branchName); + commitInWorktree(wtDir, 'unmerged.txt'); + // NOTE: intentionally NOT merging the branch into main + + const metaDir = worktreeMeta(repoDir, wtDir); + const lockedFile = path.join(metaDir, 'locked'); + fs.writeFileSync(lockedFile, String(deadPid())); + + // Inject STALE_MTIME so the staleness guard passes deterministically, + // ensuring the unmerged-branch check is the only reason the entry is skipped. + const result = reapOrphanWorktrees(repoDir, { mtimeSafe: () => STALE_MTIME }); + + const entry = result.find((r) => canonicalPath(r.path) === canonicalPath(wtDir)); + if (entry) { + assert.notEqual(entry.status, 'reaped', 'unmerged worktree must not be reaped (data loss guard)'); + } + assert.ok(fs.existsSync(wtDir), 'unmerged worktree directory must still exist'); + }); + + // ── Dead PID + merged + fresh mtime → skip (race guard) ─────────────────── + test('skips a locked worktree with fresh mtime even when pid is dead and branch is merged', () => { + const repoDir = path.join(tmpBase, 'repo4'); + const wtDir = path.join(tmpBase, 'wt-fresh-lock'); + const branchName = 'worktree-agent-fresh-lock'; + + initRepo(repoDir); + addWorktree(repoDir, wtDir, branchName); + commitInWorktree(wtDir, 'fresh.txt'); + mergeIntoMain(repoDir, branchName); + + const metaDir = worktreeMeta(repoDir, wtDir); + const lockedFile = path.join(metaDir, 'locked'); + fs.writeFileSync(lockedFile, String(deadPid())); + + // Inject FRESH_MTIME (far future) so the staleness boundary is crossed + // deterministically: Date.now() - FRESH_MTIME.getTime() is always negative, + // which is always less than REAP_MTIME_GUARD_MS. No utimesSync needed. + // Previously, this test relied on the file being just-created (real clock + // within 5 minutes) which is fragile on heavily-loaded CI hosts. + const result = reapOrphanWorktrees(repoDir, { mtimeSafe: () => FRESH_MTIME }); + + const entry = result.find((r) => canonicalPath(r.path) === canonicalPath(wtDir)); + if (entry) { + assert.notEqual(entry.status, 'reaped', 'fresh-mtime worktree must not be reaped (race guard)'); + } + assert.ok(fs.existsSync(wtDir), 'fresh-lock worktree directory must still exist'); + }); + + // ── Double invocation → idempotent ───────────────────────────────────────── + test('is idempotent: second invocation is a no-op', () => { + const repoDir = path.join(tmpBase, 'repo5'); + const wtDir = path.join(tmpBase, 'wt-idempotent'); + const branchName = 'worktree-agent-idempotent'; + + initRepo(repoDir); + addWorktree(repoDir, wtDir, branchName); + commitInWorktree(wtDir, 'idempotent.txt'); + mergeIntoMain(repoDir, branchName); + + const metaDir = worktreeMeta(repoDir, wtDir); + const lockedFile = path.join(metaDir, 'locked'); + fs.writeFileSync(lockedFile, String(deadPid())); + + // Inject STALE_MTIME so the staleness guard is deterministically satisfied. + const staleDeps = { mtimeSafe: () => STALE_MTIME }; + + const result1 = reapOrphanWorktrees(repoDir, staleDeps); + const reaped1 = result1.filter((r) => r.status === 'reaped'); + assert.equal(reaped1.length, 1, 'first invocation should reap exactly one entry'); + + // Second invocation: nothing left to reap + const result2 = reapOrphanWorktrees(repoDir, staleDeps); + const reaped2 = result2.filter((r) => r.status === 'reaped'); + assert.equal(reaped2.length, 0, 'second invocation should reap nothing (idempotent)'); + }); +}); + +// ─── Suite 3: Structural — startup sweep wiring ─────────────────────────────── + +describe('bug-3707: startup orphan sweep is wired into workflow entry points', () => { + const QUICK_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'quick.md'); + const EXECUTE_PHASE_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md'); + + test('quick.md calls worktree.reap-orphans at startup when USE_WORKTREES is not false', () => { + const content = fs.readFileSync(QUICK_PATH, 'utf8'); + assert.ok( + content.includes('worktree.reap-orphans'), + 'quick.md must call gsd-sdk query worktree.reap-orphans at startup' + ); + // Must be guarded by USE_WORKTREES check + assert.ok( + /USE_WORKTREES.*!=.*false[\s\S]{0,200}worktree\.reap-orphans/m.test(content) || + /worktree\.reap-orphans[\s\S]{0,200}USE_WORKTREES.*!=.*false/m.test(content), + 'quick.md startup sweep must be guarded by USE_WORKTREES != false' + ); + }); + + test('execute-phase.md calls worktree.reap-orphans at startup when USE_WORKTREES is not false', () => { + const content = fs.readFileSync(EXECUTE_PHASE_PATH, 'utf8'); + assert.ok( + content.includes('worktree.reap-orphans'), + 'execute-phase.md must call gsd-sdk query worktree.reap-orphans at startup' + ); + assert.ok( + /USE_WORKTREES.*!=.*false[\s\S]{0,200}worktree\.reap-orphans/m.test(content) || + /worktree\.reap-orphans[\s\S]{0,200}USE_WORKTREES.*!=.*false/m.test(content), + 'execute-phase.md startup sweep must be guarded by USE_WORKTREES != false' + ); + }); + + test('worktree-safety module exports reapOrphanWorktrees', () => { + const mod = require('../gsd-core/bin/lib/worktree-safety.cjs'); + assert.strictEqual(typeof mod.reapOrphanWorktrees, 'function'); + }); + + test('worktree-safety module exports cmdWorktreeReapOrphans', () => { + const mod = require('../gsd-core/bin/lib/worktree-safety.cjs'); + assert.strictEqual(typeof mod.cmdWorktreeReapOrphans, 'function'); + }); +}); + +// ─── Suite 3b: nowMs clock-injection BOUNDARY tests (#1191) ────────────────── +// +// These tests inject both `nowMs` and `mtimeSafe` so no real clock is read. +// The staleness guard is: nowMs - lockMtime.getTime() < reapMtimeGuardMs. +// +// REAP_MTIME_GUARD_MS = 5 * 60 * 1000 = 300000 ms. +// +// We use a fixed lockMtime of 1000 ms (epoch+1s) and compute nowMs values that +// are exactly 1 ms inside (age = 299999 ms < 300000) vs exactly 1 ms outside +// (age = 300000 ms, NOT < 300000) the guard boundary. + +const KNOWN_REAP_MTIME_GUARD_MS = 5 * 60 * 1000; // 300000 ms — mirrors SUT constant +const FIXED_LOCK_MTIME_MS = 1000; // 1970-01-01T00:00:01.000Z +const FIXED_LOCK_DATE = new Date(FIXED_LOCK_MTIME_MS); + +describe('bug-3707: reapOrphanWorktrees — nowMs clock-injection BOUNDARY tests (#1191)', () => { + let tmpBase; + + beforeEach(() => { + tmpBase = fs.mkdtempSync(path.join(resolvedTmpDir(), 'gsd-3707-nowms-')); + }); + + afterEach(() => { + cleanup(tmpBase); + }); + + // ── Just-inside boundary: age = guard - 1 → skip (lock_too_fresh) ─────────── + test('skips when injected nowMs places lock age just inside guard (age < guard)', () => { + // age = nowMs - FIXED_LOCK_MTIME_MS = (FIXED_LOCK_MTIME_MS + KNOWN_REAP_MTIME_GUARD_MS - 1) - FIXED_LOCK_MTIME_MS + // = KNOWN_REAP_MTIME_GUARD_MS - 1 = 299999 ms → 299999 < 300000 → SKIP + const nowMs = FIXED_LOCK_MTIME_MS + KNOWN_REAP_MTIME_GUARD_MS - 1; + + const repoDir = path.join(tmpBase, 'repo-inside'); + const wtDir = path.join(tmpBase, 'wt-inside-guard'); + const branchName = 'worktree-boundary-inside'; + + initRepo(repoDir); + addWorktree(repoDir, wtDir, branchName); + commitInWorktree(wtDir, 'inside.txt'); + mergeIntoMain(repoDir, branchName); + + const metaDir = worktreeMeta(repoDir, wtDir); + const lockedFile = path.join(metaDir, 'locked'); + fs.writeFileSync(lockedFile, String(deadPid())); + + // Inject both nowMs and mtimeSafe — no real clock is read + const result = reapOrphanWorktrees(repoDir, { + nowMs, + mtimeSafe: () => FIXED_LOCK_DATE, + }); + + assert.ok(Array.isArray(result), 'must return an array'); + const entry = result.find((r) => canonicalPath(r.path) === canonicalPath(wtDir)); + assert.ok(entry, 'worktree must appear in results'); + assert.equal(entry.status, 'skipped', `status must be skipped when age=${nowMs - FIXED_LOCK_MTIME_MS}ms < guard=${KNOWN_REAP_MTIME_GUARD_MS}ms`); + assert.equal(entry.reason, 'lock_too_fresh', 'reason must be lock_too_fresh'); + assert.ok(fs.existsSync(wtDir), 'worktree directory must still exist (not reaped)'); + }); + + // ── Just-outside boundary: age = guard → reap (age NOT < guard) ───────────── + test('reaps when injected nowMs places lock age exactly at guard boundary (age === guard)', () => { + // age = nowMs - FIXED_LOCK_MTIME_MS = (FIXED_LOCK_MTIME_MS + KNOWN_REAP_MTIME_GUARD_MS) - FIXED_LOCK_MTIME_MS + // = KNOWN_REAP_MTIME_GUARD_MS = 300000 ms → 300000 NOT < 300000 → PROCEED TO REAP + const nowMs = FIXED_LOCK_MTIME_MS + KNOWN_REAP_MTIME_GUARD_MS; + + const repoDir = path.join(tmpBase, 'repo-outside'); + const wtDir = path.join(tmpBase, 'wt-outside-guard'); + const branchName = 'worktree-boundary-outside'; + + initRepo(repoDir); + addWorktree(repoDir, wtDir, branchName); + commitInWorktree(wtDir, 'outside.txt'); + mergeIntoMain(repoDir, branchName); + + const metaDir = worktreeMeta(repoDir, wtDir); + const lockedFile = path.join(metaDir, 'locked'); + // Use deadPid() — a truly dead process — so PID check passes and reap proceeds + fs.writeFileSync(lockedFile, String(deadPid())); + + const wtDirCanonical = canonicalPath(wtDir); + + // Inject both nowMs and mtimeSafe — no real clock is read + const result = reapOrphanWorktrees(repoDir, { + nowMs, + mtimeSafe: () => FIXED_LOCK_DATE, + }); + + assert.ok(Array.isArray(result), 'must return an array'); + const entry = result.find((r) => canonicalPath(r.path) === wtDirCanonical); + assert.ok(entry, 'worktree must appear in results'); + assert.equal(entry.status, 'reaped', `status must be reaped when age=${nowMs - FIXED_LOCK_MTIME_MS}ms >= guard=${KNOWN_REAP_MTIME_GUARD_MS}ms`); + assert.ok(!fs.existsSync(wtDir), 'worktree directory must be removed after reaping'); + }); +}); + +// ─── Suite 4: Adversarial gap tests ────────────────────────────────────────── + +describe('bug-3707: reapOrphanWorktrees — adversarial edge cases', () => { + let tmpBase; + + beforeEach(() => { + tmpBase = fs.mkdtempSync(path.join(resolvedTmpDir(), 'gsd-3707-adv-')); + }); + + afterEach(() => { + cleanup(tmpBase); + }); + + // ── Gap 1: Non-numeric lock content (real Claude Code format) → ALIVE (fail-closed) ── + test('does NOT reap a worktree whose lock contains non-numeric Claude Code content', () => { + // Claude Code writes "Locked by claude-code agent-" as the lock content. + // This is non-numeric and MUST be treated as ALIVE (fail-closed) — we cannot + // confirm the owner is dead, so reaping would risk data loss. + const repoDir = path.join(tmpBase, 'repo'); + const wtDir = path.join(tmpBase, 'wt-claude-lock'); + const branchName = 'worktree-agent-claude-lock'; + + initRepo(repoDir); + addWorktree(repoDir, wtDir, branchName); + commitInWorktree(wtDir, 'claude-work.txt'); + mergeIntoMain(repoDir, branchName); + + const metaDir = worktreeMeta(repoDir, wtDir); + const lockedFile = path.join(metaDir, 'locked'); + // Write the real Claude Code lock format (non-numeric) + fs.writeFileSync(lockedFile, 'Locked by claude-code agent-a1b2c3d4e5f6'); + + // Inject STALE_MTIME so the staleness guard passes deterministically, + // ensuring the non-numeric content check is the only reason the entry is skipped. + const result = reapOrphanWorktrees(repoDir, { mtimeSafe: () => STALE_MTIME }); + + const entry = result.find((r) => canonicalPath(r.path) === canonicalPath(wtDir)); + if (entry) { + assert.notEqual( + entry.status, + 'reaped', + 'non-numeric Claude Code lock must NOT be reaped (fail-closed: owner unknown)' + ); + assert.equal(entry.status, 'skipped', 'non-numeric lock entry should have status=skipped'); + assert.equal(entry.reason, 'lock_owner_unknown', 'reason must be lock_owner_unknown'); + } + assert.ok(fs.existsSync(wtDir), 'worktree with Claude Code lock must NOT be removed'); + }); + + // ── Gap 2: EPERM in defaultIsPidAlive → ALIVE (fail-closed) ───────────────── + test('treats EPERM from isPidAlive as ALIVE (fail-closed)', () => { + // On Windows, signalling cross-user processes throws EPERM, not ESRCH. + // The reaper must treat EPERM as ALIVE to avoid false reaping. + const repoDir = path.join(tmpBase, 'repo2'); + const wtDir = path.join(tmpBase, 'wt-eperm'); + const branchName = 'worktree-agent-eperm'; + + initRepo(repoDir); + addWorktree(repoDir, wtDir, branchName); + commitInWorktree(wtDir, 'eperm-work.txt'); + mergeIntoMain(repoDir, branchName); + + const metaDir = worktreeMeta(repoDir, wtDir); + const lockedFile = path.join(metaDir, 'locked'); + fs.writeFileSync(lockedFile, String(deadPid())); + + // Inject an isPidAlive that always throws EPERM — simulates Windows cross-user scenario. + // Also inject STALE_MTIME so the staleness guard is deterministically satisfied. + const epermIsPidAlive = (_pid) => { + const err = new Error('EPERM: operation not permitted'); + err.code = 'EPERM'; + throw err; + }; + + const result = reapOrphanWorktrees(repoDir, { + isPidAlive: epermIsPidAlive, + mtimeSafe: () => STALE_MTIME, + }); + + const entry = result.find((r) => canonicalPath(r.path) === canonicalPath(wtDir)); + if (entry) { + assert.notEqual(entry.status, 'reaped', 'EPERM from isPidAlive must be treated as ALIVE — must not reap'); + } + assert.ok(fs.existsSync(wtDir), 'worktree must still exist when isPidAlive throws EPERM'); + }); + + // ── Gap 3: Non-main/master default branch via init.defaultBranch ───────────── + test('uses init.defaultBranch config when default branch is not main or master', () => { + // Repos configured with init.defaultBranch=trunk (or dev, etc.) were + // previously unreachable by the main/master fallback, causing the reaper + // to bail out and silently skip all orphan detection. + const repoDir = path.join(tmpBase, 'repo3'); + const wtDir = path.join(tmpBase, 'wt-trunk-default'); + const branchName = 'worktree-agent-trunk-merged'; + + // Create a repo whose default branch is 'trunk' + fs.mkdirSync(repoDir, { recursive: true }); + git(['init'], repoDir); + git(['config', 'user.email', 'test@test.com'], repoDir); + git(['config', 'user.name', 'Test'], repoDir); + git(['config', 'commit.gpgsign', 'false'], repoDir); + // Set init.defaultBranch to 'trunk' so the reaper discovers it + git(['config', 'init.defaultBranch', 'trunk'], repoDir); + fs.writeFileSync(path.join(repoDir, 'README.md'), '# Trunk Test\n'); + git(['add', '-A'], repoDir); + git(['commit', '-m', 'initial commit'], repoDir); + // Rename to trunk (may fail if already trunk) + try { git(['branch', '-m', 'master', 'trunk'], repoDir); } catch { /* already trunk or main */ } + try { git(['branch', '-m', 'main', 'trunk'], repoDir); } catch { /* already trunk */ } + + addWorktree(repoDir, wtDir, branchName); + commitInWorktree(wtDir, 'trunk-work.txt'); + // Merge branch into trunk + git(['merge', branchName, '--no-ff', '-m', 'merge into trunk'], repoDir); + + const metaDir = worktreeMeta(repoDir, wtDir); + const lockedFile = path.join(metaDir, 'locked'); + fs.writeFileSync(lockedFile, String(deadPid())); + + // Inject STALE_MTIME so the staleness guard is deterministically satisfied. + // Pre-compute canonical before reaping (symlink resolution may fail post-removal). + const wtDirCanonical = canonicalPath(wtDir); + + const result = reapOrphanWorktrees(repoDir, { mtimeSafe: () => STALE_MTIME }); + + // The reaper must either reap the worktree (using trunk as the default branch) + // OR skip it for a safe reason — it must NOT return an empty result (which + // would mean it bailed out entirely, silently skipping all orphan detection). + assert.ok(Array.isArray(result), 'reapOrphanWorktrees must return an array'); + assert.ok(result.length > 0, 'reaper must not bail out entirely for trunk-default repos — must inspect the worktree'); + const entry = result.find((r) => canonicalPath(r.path) === wtDirCanonical); + assert.ok(entry, 'worktree must appear in results (reaped or skipped with reason)'); + // The branch IS merged into trunk, and the PID is dead, so it should be reaped. + assert.equal(entry.status, 'reaped', 'worktree with dead pid merged into trunk must be reaped'); + }); +}); + }); +}