test(#1971): consolidate 48 gsd-tools CLI regression tests into subcommand suites

Fold 48 issue-named gsd-tools CLI regression files into the canonical test file
that owns each subcommand subject (state, roadmap, phase, milestone, audit, config,
router/dispatch, stats, verify, health, etc.), preserving every assertion and its
origin issue number as provenance (block-scoped describe wrappers, 299 subtests
conserved 1:1). No monolithic gsd-tools.test.cjs created — routes into 18 existing
per-subject suites.

Removes 48 tests/ files. Regenerates regression-name allowlist (271->231), ratchets
the file-count allowlist across 6 buckets (audit/milestone/phase/roadmap/state/verify),
and makes 10 relocated allow-test-rule exemptions issue-ref-compliant (ADR-456; prunes
10 stale ids). Repoints one CONTEXT.md symptom ref and ADR-3524's parity-test ref.
lint:ci green.

Part of epic #1969. Closes #1971.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-07-03 01:22:41 -04:00
parent 89ecee06fc
commit de3ba45d00
71 changed files with 10731 additions and 10317 deletions

View File

@@ -931,7 +931,7 @@ Full detail in `~/.claude/skills/gsd-pr-fix-discipline/SKILL.md`. AI agents MUST
### Slash command two-tier confusion
- **Symptom:** `tests/bug-2543-gsd-slash-namespace.test.cjs` or `tests/bug-3584-runtime-slash-emitters.test.cjs` fails
- **Symptom:** `tests/bug-2543-gsd-slash-namespace.test.cjs` or `tests/init-manager.test.cjs` (folds former `bug-3584-runtime-slash-emitters`, consolidation epic #1969) fails
- **Affected this session:** #154 (three passes), #164 (added the authoritative matrix)
- **Fix:** Consult `## Slash-command form` section of this file before touching any `/gsd-` or `/gsd:` token — colon for `agents/`/`commands/`, hyphen for runtime emitters

View File

@@ -137,7 +137,7 @@ parsing from `sdk/dist/query/validate.js`, the same technique used for `escapeRe
`gen-phase-lifecycle-policy.mjs`. The function is deterministic and pure: no closures over external
state, no side effects.
**Parity tests:** `tests/6-validate-cjs-drift-regression.test.cjs` — 5 tests (all GREEN after fix,
**Parity tests:** `tests/health-validation.test.cjs` (folds former `6-validate-cjs-drift-regression`, consolidation epic #1969) — 5 tests (all GREEN after fix,
all RED on pre-fix `origin/main`). Covers each drift item with concrete fixtures:
- Drift 1: two milestone archives (v1.0 old, v1.1 active); v1.0 phase absent from ROADMAP.
- Drift 2: ROADMAP "01A", disk "1A-foo" — padding mismatch.

View File

@@ -25,7 +25,6 @@
"tests/bug-214-phase-researcher-write-truncation-contract.test.cjs :: source-text-is-the-product",
"tests/bug-214-writer-agents-write-truncation-contract.test.cjs :: source-text-is-the-product",
"tests/bug-222-research-synthesizer-write-contract.test.cjs :: source-text-is-the-product",
"tests/bug-224-pick-stdout-capture.test.cjs :: structural-implementation-guard",
"tests/bug-2346-agent-read-loop-guards.test.cjs :: source-text-is-the-product",
"tests/bug-2388-plan-phase-no-branch-rename.test.cjs :: source-text-is-the-product",
"tests/bug-2396-makefile-test-priority.test.cjs :: source-text-is-the-product",
@@ -41,7 +40,6 @@
"tests/bug-2543-gsd-slash-namespace.test.cjs :: structural-regression-guard",
"tests/bug-2549-2550-2552-discuss-phase-context.test.cjs :: source-text-is-the-product",
"tests/bug-2559-stale-search-year.test.cjs :: source-text-is-the-product",
"tests/bug-2661-roadmap-sync-parallel.test.cjs :: source-text-is-the-product",
"tests/bug-2686-review-fix-worktree.test.cjs :: source-text-is-the-product",
"tests/bug-2770-annotate-deps-int-coerce.test.cjs :: source-text-is-the-product",
"tests/bug-2772-gitmodules-path-intersection.test.cjs :: source-text-is-the-product",
@@ -53,7 +51,6 @@
"tests/bug-2954-help-md-slash-command-stubs.test.cjs :: source-text-is-the-product",
"tests/bug-2973-profile-user-skills-path.test.cjs :: source-text-is-the-product. profile-user.md IS the",
"tests/bug-2990-code-fixer-worktree-branch.test.cjs :: source-text-is-the-product",
"tests/bug-3086-git-create-tag-config-gate.test.cjs :: workflow-markdown-is-the-runtime-contract",
"tests/bug-3096-ai-integration-phase-parallel-race.test.cjs :: reads product workflow markdown (ai-integration-phase.md) to verify structural ordering contract — not a source-grep test",
"tests/bug-3097-3099-executor-worktree-path-safety.test.cjs :: reads markdown product files (gsd-executor.md, worktree-path-safety.md) to verify structural protocol — not source-grep",
"tests/bug-3120-secure-phase-empty-register.test.cjs :: reads product workflow markdown (secure-phase.md) to verify structural guard contract — not a source-grep test",
@@ -75,7 +72,6 @@
"tests/bug-3430-planner-phase-contract.test.cjs :: source-text-is-the-product",
"tests/bug-3431-debug-command-yaml.test.cjs :: source-text-is-the-product",
"tests/bug-3446-resume-continue-here-discovery.test.cjs :: source-text-is-the-product",
"tests/bug-3489-complete-phase-idempotent.test.cjs :: source-text-is-the-product",
"tests/bug-3491-nested-git-worktree.test.cjs :: source-text-is-the-product",
"tests/bug-3516-reapply-patches-gsd-update-filter.test.cjs :: source-text-is-the-product",
"tests/bug-3521-quick-cleanup-cwd-pin.test.cjs :: source-text-is-the-product",
@@ -91,7 +87,6 @@
"tests/bug-3683-command-cross-reference-invariant.test.cjs :: source-text-is-the-product",
"tests/bug-3683-workflow-colon-namespace-leak.test.cjs :: source-text-is-the-product",
"tests/bug-3689-resume-glob-nomatch.test.cjs :: source-text-is-the-product",
"tests/bug-3691-annotate-deps-plans-block-variants.test.cjs :: source-text-is-the-product",
"tests/bug-3706-ui-safety-gate-false-positives.test.cjs :: source-text-is-the-product",
"tests/bug-3707-locked-worktree-cleanup.test.cjs :: source-text-is-the-product",
"tests/bug-3727-code-review-fix-flag-dispatch.test.cjs :: source-text-is-the-product",
@@ -99,7 +94,6 @@
"tests/bug-3784-gsd-settings-model-profile-ui-omits-adaptive.test.cjs :: source-text-is-the-product",
"tests/bug-3805-fast-md-log-to-state-schema.test.cjs :: source-text-is-the-product",
"tests/bug-3810-no-gsd-sdk-runtime-refs.test.cjs :: source-text-is-the-product",
"tests/bug-397-state-preserve-executor-authored.test.cjs :: reads runtime STATE.md written to temp dir — behavioral output test, not source-grep",
"tests/bug-444-resolver-local-claude-install.test.cjs :: structural/behavioral regression for the repo-local .claude/ install",
"tests/bug-474-clock-seam-date-determinism.test.cjs :: source-text-is-the-product",
"tests/bug-503-update-agent-antigravity-detection.test.cjs :: source-text-is-the-product",
@@ -116,8 +110,6 @@
"tests/bug-936-no-nested-spawner-wrap.test.cjs :: source-text-is-the-product",
"tests/bug-947-hermes-gsd-prefix.test.cjs :: source-text-is-the-product",
"tests/bug-950-quick-summary-status-complete.test.cjs :: source-text-is-the-product",
"tests/bug-967-verify-key-links-strict-paths.test.cjs :: <runtime-contract-is-the-product> the plan-md.md reference",
"tests/bug-967-verify-key-links-strict-paths.test.cjs :: <runtime-contract-is-the-product> the plan-md.md reference example IS the documented authoring surface for key_links; asserting it uses a file path (not an endpoint) directly tests the documented contract.",
"tests/bug-patterns-reference.test.cjs :: source-text-is-the-product",
"tests/chain-flag-plan-phase.test.cjs :: source-text-is-the-product",
"tests/changeset-cli.test.cjs :: reads a product workflow .md file (not CJS source) to verify",
@@ -162,7 +154,6 @@
"tests/enh-2430-learnings-consumption.test.cjs :: source-text-is-the-product",
"tests/enh-2433-todo-phase-linking.test.cjs :: source-text-is-the-product",
"tests/enh-2446-milestones-drift.test.cjs :: source-text-is-the-product",
"tests/enh-2447-roadmap-wave-deps.test.cjs :: source-text-is-the-product",
"tests/enh-2448-artifact-registry.test.cjs :: source-text-is-the-product",
"tests/enh-2500-codebase-mapper-arch-rich-format.test.cjs :: source-text-is-the-product",
"tests/enh-2789-description-budget.test.cjs :: source-text-is-the-product",
@@ -191,7 +182,6 @@
"tests/feat-22-surfacing-docs.test.cjs :: docs-parity",
"tests/feat-2840-issue-driven-orchestration-guide.test.cjs :: structural-IR parser for a docs guide. The .includes()",
"tests/feat-3039-help-tiered.test.cjs :: source-text-is-the-product",
"tests/feat-3167-ship-pr-body-sections.test.cjs :: source-text-is-the-product",
"tests/feat-3210-fallow-integration.test.cjs :: source-text-is-the-product",
"tests/feat-3210-fallow-integration.test.cjs :: source-text-is-the-product — code-review.md IS the workflow the orchestrator",
"tests/feat-3309-human-verify-mode.test.cjs :: source-text-is-the-product",

View File

@@ -3,14 +3,9 @@
"bug-131-release-tarball-smoke-explicit-home.test.cjs",
"bug-1367-claude-local-flat-command-layout.test.cjs",
"bug-14-progress-auto-flag-dropped.test.cjs",
"bug-167-query-meta-command.test.cjs",
"bug-1695-state-patch-clobbers-phase-name.test.cjs",
"bug-17-askuserquestion-option-cap.test.cjs",
"bug-170-workflow-fallback-install-hint.test.cjs",
"bug-1760-state-prune-noop-template-field.test.cjs",
"bug-1761-state-sync-wrong-progress.test.cjs",
"bug-1818-unknown-flags.test.cjs",
"bug-1826-phases-clear-confirm.test.cjs",
"bug-1829-inherit-model-profile.test.cjs",
"bug-1834-sh-hooks-installed.test.cjs",
"bug-1891-file-resolution.test.cjs",
@@ -26,8 +21,6 @@
"bug-214-phase-researcher-write-truncation-contract.test.cjs",
"bug-214-writer-agents-write-truncation-contract.test.cjs",
"bug-222-research-synthesizer-write-contract.test.cjs",
"bug-224-pick-stdout-capture.test.cjs",
"bug-2268-parallel-discuss.test.cjs",
"bug-2344-read-guard-claudecode-env.test.cjs",
"bug-2346-agent-read-loop-guards.test.cjs",
"bug-2351-intel-kilo-layout.test.cjs",
@@ -57,15 +50,10 @@
"bug-2557-gemini-local-hook-paths.test.cjs",
"bug-2559-stale-search-year.test.cjs",
"bug-260-worktree-path-guard.test.cjs",
"bug-2601-inherit-model-profile.test.cjs",
"bug-261-worktree-force-add-guard.test.cjs",
"bug-2630-state-frontmatter-milestone-switch.test.cjs",
"bug-2638-sub-repos-canonical-location.test.cjs",
"bug-2659-audit-open-crash.test.cjs",
"bug-2660-one-liner-extraction.test.cjs",
"bug-2661-roadmap-sync-parallel.test.cjs",
"bug-2686-review-fix-worktree.test.cjs",
"bug-2769-requirements-header-variants.test.cjs",
"bug-2770-annotate-deps-int-coerce.test.cjs",
"bug-2772-gitmodules-path-intersection.test.cjs",
"bug-2784-update-cache-clear-path.test.cjs",
@@ -78,10 +66,8 @@
"bug-2851-workflow-bare-gsd-tools.test.cjs",
"bug-2866-codex-strip-no-trailing-newline.test.cjs",
"bug-2876-skill-frontmatter-quote.test.cjs",
"bug-2911-audit-open-output-shape.test.cjs",
"bug-2912-progress-context-authority.test.cjs",
"bug-2916-handle-branching-default-base.test.cjs",
"bug-2942-detect-custom-skills.test.cjs",
"bug-2943-config-get-context-window-default.test.cjs",
"bug-2948-spike-wrap-up-dispatch.test.cjs",
"bug-2949-sketch-wrap-up-dispatch.test.cjs",
@@ -99,7 +85,6 @@
"bug-3054-stale-gsd-next-references.test.cjs",
"bug-3072-optional-sketch-findings-guard.test.cjs",
"bug-3083-resume-route-clear.test.cjs",
"bug-3086-git-create-tag-config-gate.test.cjs",
"bug-3087-planner-directive-language.test.cjs",
"bug-3096-ai-integration-phase-parallel-race.test.cjs",
"bug-3097-3099-executor-worktree-path-safety.test.cjs",
@@ -110,22 +95,15 @@
"bug-3129-validate-commit-git-bypass.test.cjs",
"bug-3130-update-npx-robust-invocation.test.cjs",
"bug-3135-capture-backlog-workflow.test.cjs",
"bug-3150-stats-json-decimal-phase-gaps.test.cjs",
"bug-3156-plan-phase-opencode-dispatch.test.cjs",
"bug-3163-codex-agents-md.test.cjs",
"bug-3168-task-to-agent-rename.test.cjs",
"bug-3195-quick-resurrection-guard.test.cjs",
"bug-3197-gsd-tools-config-whitelist.test.cjs",
"bug-321-config-defaults-clone-strategy.test.cjs",
"bug-3212-execute-phase-stall-safe-resume.test.cjs",
"bug-3227-config-set-model-overrides.test.cjs",
"bug-3236-capture-seed-one-shot.test.cjs",
"bug-3242-state-update-progress-trample.test.cjs",
"bug-3243-dotted-command-form.test.cjs",
"bug-3257-nested-plans-undercount.test.cjs",
"bug-3258-no-stale-gsd-intel-references.test.cjs",
"bug-3275-fmstr-non-string-scalars.test.cjs",
"bug-3286-state-write-routing.test.cjs",
"bug-3290-intel-updater-layout-block.test.cjs",
"bug-33-settings-model-profile-adaptive.test.cjs",
"bug-3320-planner-deep-work-rules.test.cjs",
@@ -141,25 +119,19 @@
"bug-3442-codex-legacy-hooks-json-migration.test.cjs",
"bug-3442-shim-projection-drift-guard.test.cjs",
"bug-3446-resume-continue-here-discovery.test.cjs",
"bug-3454-state-dollar-backreference-growth.test.cjs",
"bug-3489-complete-phase-idempotent.test.cjs",
"bug-3491-nested-git-worktree.test.cjs",
"bug-3509-path-spaces.test.cjs",
"bug-3516-reapply-patches-gsd-update-filter.test.cjs",
"bug-3521-quick-cleanup-cwd-pin.test.cjs",
"bug-3523-cjs-loadconfig-branching-strategy-warning.test.cjs",
"bug-3537-padded-id-against-unpadded-roadmap.test.cjs",
"bug-3541-installer-migration-prompt-user-resolution.test.cjs",
"bug-3542-executor-git-stash-prohibition.test.cjs",
"bug-3584-runtime-slash-emitters.test.cjs",
"bug-3584-runtime-slash-formatter.test.cjs",
"bug-3588-npm-audit-clean.test.cjs",
"bug-3599-roadmap-get-phase-project-code-prefix.test.cjs",
"bug-3605-stale-research-insert-phase-agent-refs.test.cjs",
"bug-3608-antigravity-update-runtime-classification.test.cjs",
"bug-3610-installer-migration-bundled-hooks-classification.test.cjs",
"bug-3628-bundled-hook-classifier-whitelist.test.cjs",
"bug-3631-router-raw-flag.test.cjs",
"bug-3657-verify-reapply-patches-pristine-drift.test.cjs",
"bug-3659-applysurface-prune-skill-dirs.test.cjs",
"bug-3668-workflow-runtime-resolution.test.cjs",
@@ -170,7 +142,6 @@
"bug-3683-command-cross-reference-invariant.test.cjs",
"bug-3683-workflow-colon-namespace-leak.test.cjs",
"bug-3689-resume-glob-nomatch.test.cjs",
"bug-3691-annotate-deps-plans-block-variants.test.cjs",
"bug-3706-ui-safety-gate-false-positives.test.cjs",
"bug-3707-locked-worktree-cleanup.test.cjs",
"bug-3727-code-review-fix-flag-dispatch.test.cjs",
@@ -181,17 +152,12 @@
"bug-3805-fast-md-log-to-state-schema.test.cjs",
"bug-3810-no-gsd-sdk-runtime-refs.test.cjs",
"bug-384-agents-runtime-aware.test.cjs",
"bug-397-state-preserve-executor-authored.test.cjs",
"bug-416-archive-dir-null.test.cjs",
"bug-444-resolver-local-claude-install.test.cjs",
"bug-447-gap-analysis-phase-req-ids.test.cjs",
"bug-474-clock-seam-date-determinism.test.cjs",
"bug-492-effort-manifest-fallback.test.cjs",
"bug-500-planned-phase-progress-corruption.test.cjs",
"bug-501-flat-phase-details-milestone-leak.test.cjs",
"bug-503-update-agent-antigravity-detection.test.cjs",
"bug-549-total-phases-overcounts-with-phase-section-heading.test.cjs",
"bug-557-details-summary-milestone-strip.test.cjs",
"bug-571-doc-writer-fix-mode-edit-only.test.cjs",
"bug-580-local-sh-hook-bash-wrapper.test.cjs",
"bug-619-codebase-drift-gate-shim.test.cjs",
@@ -205,20 +171,14 @@
"bug-687-agy-timeout.test.cjs",
"bug-730-milestone-phase-details-scope.test.cjs",
"bug-783-kilo-global-skills-base.test.cjs",
"bug-853-bg-dispatch-runtime-gating.test.cjs",
"bug-866-profile-pipeline-temp-root.test.cjs",
"bug-891-non-claude-runtime-home-fallback.test.cjs",
"bug-892-validate-checklist-roadmap-phases.test.cjs",
"bug-905-state-syncstatefrontmatter-preserve-scalars.test.cjs",
"bug-924-claude-flat-skill-layout.test.cjs",
"bug-925-context-monitor-hook-event-name.test.cjs",
"bug-936-no-nested-spawner-wrap.test.cjs",
"bug-941-managed-hooks-registry-manifest.test.cjs",
"bug-947-hermes-gsd-prefix.test.cjs",
"bug-948-state-noop-write-guard.test.cjs",
"bug-950-quick-summary-status-complete.test.cjs",
"bug-967-verify-key-links-strict-paths.test.cjs",
"bug-969-test-infra-flake-hardening.test.cjs",
"bug-974-graphify-budget-missing-value.test.cjs",
"bug-978-milestone-complete-force.test.cjs"
"bug-974-graphify-budget-missing-value.test.cjs"
]

View File

@@ -5,9 +5,7 @@
"files": [
"audit-command-cutover.test.cjs",
"audit-fix-command.test.cjs",
"bug-2659-audit-open-crash.test.cjs",
"bug-2836-audit-open-summary-uat-drift.test.cjs",
"bug-2911-audit-open-output-shape.test.cjs"
"bug-2836-audit-open-summary-uat-drift.test.cjs"
],
"issue": "192"
},
@@ -49,7 +47,6 @@
"milestone": {
"files": [
"bug-730-milestone-phase-details-scope.test.cjs",
"bug-978-milestone-complete-force.test.cjs",
"milestone-archive.test.cjs",
"milestone-helper.test.cjs",
"milestone-prefixed-convention.test.cjs",
@@ -60,7 +57,6 @@
},
"phase": {
"files": [
"fix-1437-phase-list-plans.test.cjs",
"bug-214-phase-researcher-write-truncation-contract.test.cjs",
"phase-dependency-levels.test.cjs",
"phase.test.cjs"
@@ -69,10 +65,7 @@
},
"roadmap": {
"files": [
"bug-2661-roadmap-sync-parallel.test.cjs",
"bug-3128-roadmap-plan-count-slug-layout.test.cjs",
"bug-3599-roadmap-get-phase-project-code-prefix.test.cjs",
"enh-2447-roadmap-wave-deps.test.cjs",
"roadmap-mode-field.test.cjs",
"roadmap-phase-fallback.test.cjs",
"roadmap.test.cjs"
@@ -99,17 +92,9 @@
"state": {
"files": [
"bug-1695-state-patch-clobbers-phase-name.test.cjs",
"bug-1760-state-prune-noop-template-field.test.cjs",
"bug-1761-state-sync-wrong-progress.test.cjs",
"bug-21-state-md-template-frontmatter.test.cjs",
"bug-2630-state-frontmatter-milestone-switch.test.cjs",
"bug-3127-state-begin-phase-idempotent.test.cjs",
"bug-3242-state-update-progress-trample.test.cjs",
"bug-3286-state-write-routing.test.cjs",
"bug-3454-state-dollar-backreference-growth.test.cjs",
"bug-397-state-preserve-executor-authored.test.cjs",
"bug-905-state-syncstatefrontmatter-preserve-scalars.test.cjs",
"bug-948-state-noop-write-guard.test.cjs",
"state-acquirestatelock-non-eexist.test.cjs",
"state-prune.test.cjs",
"state-rebuild-cli.test.cjs",
@@ -124,7 +109,6 @@
"bug-2994-verify-reapply-patches-installed-path.test.cjs",
"bug-3381-verify-work-workstream.test.cjs",
"bug-3657-verify-reapply-patches-pristine-drift.test.cjs",
"bug-967-verify-key-links-strict-paths.test.cjs",
"verify-health.test.cjs",
"verify-mvp-uat.test.cjs",
"verify-npm-publish.test.cjs",

View File

@@ -1,304 +0,0 @@
'use strict';
/**
* Integration tests for `phase uat-passed <N>` CLI command.
* Issue #247 — phase uat-passed predicate
*
* Tests the full dispatch path: gsd-tools → phase-command-router → phase.cmdPhaseUatPassed
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
// ─── Helpers ──────────────────────────────────────────────────────────────────
/**
* Set up a minimal project with a phase directory and ROADMAP so that
* findPhaseInternal(cwd, phaseNum) can resolve it.
* Returns { tmpDir, phaseDir }.
*/
function setupProject(phaseSlug = '01-feature') {
const tmpDir = createTempProject();
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'# Roadmap',
'',
'- [ ] Phase 1: Feature',
'',
'### Phase 1: Feature',
'**Goal:** Build feature',
'**Plans:** 1 plans',
'',
].join('\n'),
);
const phaseDir = path.join(tmpDir, '.planning', 'phases', phaseSlug);
fs.mkdirSync(phaseDir, { recursive: true });
return { tmpDir, phaseDir };
}
function writeUatFile(phaseDir, filename, content) {
fs.writeFileSync(path.join(phaseDir, filename), content, 'utf-8');
}
function setMtime(filePath, time) {
fs.utimesSync(filePath, time, time);
}
function makePassingUat() {
return [
'---',
'status: passed',
'---',
'',
'# UAT Results',
'',
'### 1. Login works',
'expected: User logs in successfully',
'result: passed',
'',
].join('\n');
}
function makePendingUat() {
return [
'---',
'status: partial',
'---',
'',
'# UAT Results',
'',
'### 1. Login works',
'expected: User logs in successfully',
'result: passed',
'',
'### 2. Logout works',
'expected: User logs out successfully',
'result: pending',
'',
].join('\n');
}
function makeFencedFalsePositiveUat() {
// Only "result: passed" lines are inside a fenced block.
// The real test has result: pending → should evaluate to passed:false.
return [
'---',
'status: partial',
'---',
'',
'# UAT Results',
'',
'## Example (do not run)',
'```',
'### 1. Test',
'expected: Example',
'result: passed',
'```',
'',
'### 1. Real Test',
'expected: The thing works',
'result: pending',
'',
].join('\n');
}
// ─── Basic pass/fail cases ─────────────────────────────────────────────────────
describe('phase uat-passed — basic pass/fail', () => {
let tmpDir;
let phaseDir;
beforeEach(() => {
({ tmpDir, phaseDir } = setupProject('01-feature'));
});
afterEach(() => {
cleanup(tmpDir);
});
test('passing UAT → passed:true with correct JSON shape', () => {
writeUatFile(phaseDir, 'feature-UAT.md', makePassingUat());
const result = runGsdTools('phase uat-passed 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}\nOutput: ${result.output}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.passed, true);
assert.strictEqual(out.phase, '1');
assert.ok(Array.isArray(out.uat_files), 'uat_files must be an array');
assert.ok(Array.isArray(out.verification_files), 'verification_files must be an array');
assert.ok(Array.isArray(out.checks), 'checks must be an array');
assert.ok(Array.isArray(out.blockers), 'blockers must be an array');
assert.ok(out.policy && typeof out.policy.require_verification === 'boolean',
'policy.require_verification must be a boolean');
assert.strictEqual(typeof out.no_uat_artifacts, 'boolean', 'no_uat_artifacts must be a boolean');
assert.strictEqual(out.no_uat_artifacts, false, 'no_uat_artifacts must be false when checks exist');
assert.strictEqual(out.blockers.length, 0);
});
test('pending UAT → passed:false', () => {
writeUatFile(phaseDir, 'feature-UAT.md', makePendingUat());
const result = runGsdTools('phase uat-passed 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.passed, false);
assert.strictEqual(out.phase, '1');
assert.ok(out.blockers.length > 0, 'Should have blockers for pending test');
});
test('false-positive only (fenced block) → passed:false', () => {
writeUatFile(phaseDir, 'feature-UAT.md', makeFencedFalsePositiveUat());
const result = runGsdTools('phase uat-passed 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.passed, false,
'result:passed inside a fenced block must not flip the predicate to passed');
});
test('no UAT files → passed:false + no_uat_artifacts:true (fail-closed, no vacuous pass)', () => {
// Phase directory exists but has no UAT files — fail-closed: absence is NOT a pass
const result = runGsdTools('phase uat-passed 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.passed, false,
'Phase with no UAT files must NOT vacuously pass — fail-closed predicate');
assert.strictEqual(out.no_uat_artifacts, true,
'no_uat_artifacts must be true when no UAT items found');
assert.deepStrictEqual(out.uat_files, []);
});
});
// ─── --require-verification flag ──────────────────────────────────────────────
describe('phase uat-passed — --require-verification flag', () => {
let tmpDir;
let phaseDir;
beforeEach(() => {
({ tmpDir, phaseDir } = setupProject('01-feature'));
});
afterEach(() => {
cleanup(tmpDir);
});
test('--require-verification with no verification file → passed:false', () => {
writeUatFile(phaseDir, 'feature-UAT.md', makePassingUat());
const result = runGsdTools('phase uat-passed 1 --require-verification', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.passed, false,
'require-verification with no verification file should fail');
assert.strictEqual(out.policy.require_verification, true);
assert.ok(out.blockers.some(b => /verification required/i.test(b)),
`Expected verification-required blocker, got: ${JSON.stringify(out.blockers)}`);
});
test('--require-verification with passing verification → passed:true', () => {
writeUatFile(phaseDir, 'feature-UAT.md', makePassingUat());
writeUatFile(phaseDir, 'feature-VERIFICATION.md', '---\nstatus: passed\n---\n\nVerified OK.');
const result = runGsdTools('phase uat-passed 1 --require-verification', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.passed, true);
assert.strictEqual(out.policy.require_verification, true);
});
test('--require-verification with stale passed verification → passed:false', () => {
writeUatFile(phaseDir, 'feature-UAT.md', makePassingUat());
const verificationPath = path.join(phaseDir, 'feature-VERIFICATION.md');
const summaryPath = path.join(phaseDir, 'feature-SUMMARY.md');
writeUatFile(phaseDir, 'feature-VERIFICATION.md', '---\nstatus: passed\n---\n\nVerified OK.');
writeUatFile(phaseDir, 'feature-SUMMARY.md', '# Summary\n\nImplementation changed after verification.\n');
const now = new Date();
setMtime(verificationPath, new Date(now.getTime() - 60_000));
setMtime(summaryPath, now);
const result = runGsdTools('phase uat-passed 1 --require-verification', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.passed, false);
assert.ok(
out.blockers.some(b => /verification status=stale/i.test(b)),
`Expected stale-verification blocker, got: ${JSON.stringify(out.blockers)}`,
);
});
test('--require-verification with non-canonical complete verification → passed:false', () => {
writeUatFile(phaseDir, 'feature-UAT.md', makePassingUat());
writeUatFile(phaseDir, 'feature-VERIFICATION.md', '---\nstatus: complete\n---\n\nLegacy OK.');
const result = runGsdTools('phase uat-passed 1 --require-verification', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.passed, false);
assert.ok(out.blockers.some(b => /verification required/i.test(b)),
`Expected verification-required blocker, got: ${JSON.stringify(out.blockers)}`);
});
});
// ─── Error cases ──────────────────────────────────────────────────────────────
describe('phase uat-passed — error cases', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
// Write a minimal ROADMAP so phase 1 exists
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'# Roadmap',
'',
'### Phase 1: Feature',
'**Goal:** Build feature',
'',
].join('\n'),
);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-feature'), { recursive: true });
});
afterEach(() => {
cleanup(tmpDir);
});
test('missing phase number → error message', () => {
const result = runGsdTools('phase uat-passed', tmpDir);
assert.ok(!result.success, 'Should fail with no phase number');
assert.ok(
result.error.includes('phase number required') ||
result.error.includes('Available:'),
`Expected phase-number-required error, got: ${result.error}`,
);
});
test('unknown phase number → error message', () => {
const result = runGsdTools('phase uat-passed 99', tmpDir);
assert.ok(!result.success, 'Should fail for unknown phase');
assert.ok(
result.error.includes('not found') || result.error.includes('99'),
`Expected not-found error, got: ${result.error}`,
);
});
test('unknown flag (typo --require-verifcation) → InvalidArgs error, not silent pass', () => {
const result = runGsdTools('phase uat-passed 1 --require-verifcation', tmpDir);
assert.ok(!result.success,
'Unknown flag must cause an error, not silently pass');
assert.ok(
result.error.includes('--require-verifcation') ||
result.error.includes('does not support') ||
result.error.includes('invalid'),
`Expected unknown-flag error, got: ${result.error}`,
);
});
});

View File

@@ -1,304 +0,0 @@
'use strict';
/**
* Regression tests for issue #6 (open-gsd/gsd-core):
* Three validation behaviors present in validate.ts are missing from verify.cjs,
* producing silent false negatives on the CJS production path.
*
* Three drift items fixed by porting phaseVariants() and activeDiskPhases to verify.cjs:
*
* 1. W007 activeDiskPhases — verify.cjs uses diskPhases (includes archived) for the
* W007 check; validate.ts uses activeDiskPhases (active phasesDir only). Archived
* phases absent from current ROADMAP trigger false W007 in verify.cjs.
*
* 2. phaseVariants() normalization — validate.ts has a phaseVariants() function
* generating padded/unpadded/letter-suffix variants for matching. verify.cjs uses
* only parseInt→padded (drops letter suffix), causing false W006/W007 for
* letter-suffix phases with padding mismatch between ROADMAP and disk.
*
* 3. W006 unchecked-phase variant skip — same phaseVariants() gap causes false W006
* for phases with padding mismatch: ROADMAP says "3B", disk has "03B-foo", but
* verify.cjs padded("3B") = "03" (drops letter) → "03B" on disk not matched.
*
* References:
* - ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md)
* - Issue #6 (open-gsd/gsd-core)
* - PR #154 (issue #4) — precedent for the generator pattern
*/
const { describe, test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { runGsdTools, cleanup } = require('./helpers.cjs');
// ── Fixture helpers ──────────────────────────────────────────────────────────
function mkplanning(base) {
const planningDir = path.join(base, '.planning');
const phasesDir = path.join(planningDir, 'phases');
fs.mkdirSync(phasesDir, { recursive: true });
return { planningDir, phasesDir };
}
function writeProjectMd(planningDir) {
fs.writeFileSync(
path.join(planningDir, 'PROJECT.md'),
'# Project\n\n## What This Is\nTest.\n\n## Core Value\nTest.\n\n## Requirements\nTest.\n',
);
}
function writeStateMd(planningDir, phase = '2') {
fs.writeFileSync(
path.join(planningDir, 'STATE.md'),
`# State\n\n**Current Phase:** ${phase}\n**Status:** In progress\n`,
);
}
function writeConfigJson(planningDir) {
fs.writeFileSync(
path.join(planningDir, 'config.json'),
JSON.stringify({ model_profile: 'balanced' }),
);
}
// ── Drift Item 1: W007 activeDiskPhases ────────────────────────────────────
//
// Project has a shipped milestone archive (milestones/v1.0-phases/) with old phase
// "1" inside, and active phasesDir with phase "2". Current ROADMAP only mentions
// Phase 2 (v1.0 phases were shipped and removed from ROADMAP).
//
// validate.ts: activeDiskPhases = phases from active phasesDir only (not archived).
// "1" is only in diskPhases (via forEachArchivedPhaseToken), not activeDiskPhases.
// W007 iterates activeDiskPhases → "1" never checked → no false W007.
//
// verify.cjs pre-fix: diskPhases = collectDiskPhases() + forEachArchivedPhaseToken().
// diskPhases includes "1" (from old archive). W007 iterates diskPhases → "1" not
// in roadmapPhases → W007 fires for "1". False positive.
describe('Drift item 1 — W007 activeDiskPhases: no false W007 for archived phases', () => {
let tmpDir;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-6-d1-'));
const { planningDir, phasesDir } = mkplanning(tmpDir);
writeProjectMd(planningDir);
writeStateMd(planningDir);
writeConfigJson(planningDir);
fs.writeFileSync(
path.join(planningDir, 'ROADMAP.md'),
[
'# Roadmap',
'',
'- [x] **Phase 2:** API',
'',
'### Phase 2: API',
'',
'## Progress',
'| Phase | Status |',
'|-------|--------|',
'| 2 | Complete |',
].join('\n'),
);
// Active phasesDir: only phase 2
fs.mkdirSync(path.join(phasesDir, '2-api'), { recursive: true });
// Current active milestone archive: v1.1-phases contains phase 2 (in ROADMAP)
fs.mkdirSync(
path.join(planningDir, 'milestones', 'v1.1-phases', '2-api'),
{ recursive: true },
);
// Old shipped milestone archive: v1.0-phases contains phase 1 (no longer in ROADMAP)
// v1.0 sorts before v1.1 so getActiveMilestoneArchiveDir returns v1.1.
// forEachArchivedPhaseToken walks BOTH v1.0 and v1.1, so diskPhases gets "1".
// collectDiskPhases (activeDiskPhases) only uses v1.1 (active archive) — "1" excluded.
fs.mkdirSync(
path.join(planningDir, 'milestones', 'v1.0-phases', '1-foundation'),
{ recursive: true },
);
});
after(() => {
cleanup(tmpDir);
});
test('no W007 for archived phase "1" absent from current ROADMAP', () => {
// validate.ts: activeDiskPhases has only "2" (from active phasesDir + v1.1 archive).
// "1" is in diskPhases (forEachArchivedPhaseToken walks v1.0) but NOT activeDiskPhases.
// W007 iterates activeDiskPhases → "1" never checked → no false W007. Correct.
// verify.cjs pre-fix: diskPhases = collectDiskPhases + forEachArchivedPhaseToken.
// diskPhases includes "1" (from v1.0 archive). W007 iterates diskPhases → "1" not
// in roadmapPhases → W007 fires for "1". False positive.
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
const data = JSON.parse(result.output);
const w007 = (data.warnings ?? []).filter((w) => w.code === 'W007');
// Filter to W007 that mentions only phase "1" (not "1A", "01A", etc.)
const w007Phase1 = w007.filter(
(w) => /\bPhase 1\b/i.test(w.message) && !/\b1[A-Z]\b/i.test(w.message),
);
assert.strictEqual(
w007Phase1.length,
0,
`Expected no W007 for archived phase 1 (v1.0 archive), got: ${JSON.stringify(w007)}`,
);
});
});
// ── Drift Item 2: phaseVariants() normalization ────────────────────────────
//
// ROADMAP has "### Phase 01A:" (zero-padded letter-suffix heading).
// Disk has directory "1A-foo" (unpadded letter-suffix form).
// These should match because phaseVariants("01A") = {"01A", "1A", "01A"}.
//
// validate.ts: diskPhases has "1A". phaseVariants("01A") includes "1A" → match → no W006.
// activeDiskPhases has "1A". phaseVariants("1A") includes "01A" → roadmapPhaseVariants
// has "01A" → match → no W007.
//
// verify.cjs pre-fix:
// W006 loop for "01A": padded = String(parseInt("01A",10)).padStart(2,'0') = "01".
// diskPhases.has("01A")? NO. diskPhases.has("01")? NO. → W006 fires. Bug.
// W007 loop for "1A": unpadded = String(parseInt("1A",10)) = "1".
// roadmapPhases.has("1A")? NO. roadmapPhases.has("1")? NO. → W007 fires. Bug.
describe('Drift item 2 — phaseVariants() normalization: letter-suffix zero-padding mismatch', () => {
let tmpDir;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-6-d2-'));
const { planningDir, phasesDir } = mkplanning(tmpDir);
writeProjectMd(planningDir);
writeStateMd(planningDir);
writeConfigJson(planningDir);
// ROADMAP: Phase 01A (zero-padded + letter suffix)
fs.writeFileSync(
path.join(planningDir, 'ROADMAP.md'),
[
'# Roadmap',
'',
'- [x] **Phase 01A:** Suffix Phase',
'',
'### Phase 01A: Suffix Phase',
'',
'## Progress',
'| Phase | Status |',
'|-------|--------|',
'| 01A | Complete |',
].join('\n'),
);
// Disk: unpadded form "1A-foo"
fs.mkdirSync(path.join(phasesDir, '1A-suffix-phase'), { recursive: true });
});
after(() => {
cleanup(tmpDir);
});
test('no false W006 when ROADMAP says 01A and disk has 1A-... (phaseVariants normalizes)', () => {
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
const data = JSON.parse(result.output);
const w006 = (data.warnings ?? []).filter((w) => w.code === 'W006');
assert.strictEqual(
w006.length,
0,
`Expected no W006 (01A == 1A after normalization), got: ${JSON.stringify(w006)}`,
);
});
test('no false W007 when disk has 1A and ROADMAP says 01A (phaseVariants normalizes)', () => {
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
const data = JSON.parse(result.output);
const w007 = (data.warnings ?? []).filter((w) => w.code === 'W007');
assert.strictEqual(
w007.length,
0,
`Expected no W007 (1A on disk matches 01A in ROADMAP), got: ${JSON.stringify(w007)}`,
);
});
});
// ── Drift Item 3: W006 false positive when disk uses zero-padded letter form ─
//
// ROADMAP has "### Phase 3B:" (unpadded letter-suffix heading).
// Disk has directory "03B-feature" (zero-padded letter-suffix form).
//
// validate.ts:
// diskPhases has "03B". phaseVariants("3B") = {"3B","3B","03B"}.
// existsOnDisk = diskPhases.has("03B") = TRUE → no W006.
// activeDiskPhases has "03B". phaseVariants("03B") = {"03B","3B","03B"}.
// roadmapPhaseVariants has {"3B","3B","03B"} → "03B" found → no W007.
//
// verify.cjs pre-fix:
// W006 for "3B": padded = parseInt("3B")=3 → "03" (drops "B").
// diskPhases.has("3B")? NO. diskPhases.has("03")? NO (dir is "03B" not "03"). → W006 fires.
// W007 for "03B": unpadded = String(parseInt("03B",10)) = "3".
// roadmapPhases.has("03B")? NO. roadmapPhases.has("3")? NO (ROADMAP has "3B" not "3"). → W007.
describe('Drift item 3 — W006 false positive when disk has zero-padded letter form', () => {
let tmpDir;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-6-d3-'));
const { planningDir, phasesDir } = mkplanning(tmpDir);
writeProjectMd(planningDir);
writeStateMd(planningDir, '3B');
writeConfigJson(planningDir);
// ROADMAP: Phase 3B (unpadded in heading)
fs.writeFileSync(
path.join(planningDir, 'ROADMAP.md'),
[
'# Roadmap',
'',
'- [x] **Phase 3B:** Feature Extension',
'',
'### Phase 3B: Feature Extension',
'',
'## Progress',
'| Phase | Status |',
'|-------|--------|',
'| 3B | Complete |',
].join('\n'),
);
// Disk: "03B-feature" (zero-padded letter-suffix form)
fs.mkdirSync(path.join(phasesDir, '03B-feature'), { recursive: true });
});
after(() => {
cleanup(tmpDir);
});
test('no false W006 when ROADMAP says 3B and disk has 03B-... (phaseVariants covers zero-padded)', () => {
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
const data = JSON.parse(result.output);
const w006 = (data.warnings ?? []).filter((w) => w.code === 'W006');
assert.strictEqual(
w006.length,
0,
`Expected no W006 (3B == 03B after normalization), got: ${JSON.stringify(w006)}`,
);
});
test('no false W007 when disk has 03B and ROADMAP says 3B (phaseVariants covers both forms)', () => {
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
const data = JSON.parse(result.output);
const w007 = (data.warnings ?? []).filter((w) => w.code === 'W007');
assert.strictEqual(
w007.length,
0,
`Expected no W007 (03B on disk matches 3B in ROADMAP), got: ${JSON.stringify(w007)}`,
);
});
});

View File

@@ -379,3 +379,216 @@ describe('audit cutover: registry entries correct', () => {
assert.strictEqual(entry.router, 'routeGraphifyCommand');
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-2659-audit-open-crash.test.cjs — consolidation epic #1969 (B2 #1971)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-2659-audit-open-crash (consolidation epic #1969 B2 #1971)", () => {
'use strict';
/**
* Regression test for #2659.
*
* The `audit-open` dispatch case in bin/gsd-tools.cjs previously called bare
* `output(...)` on both the --json and text branches. `output` is never in
* local scope — the entire core module is imported as `const core`, so every
* other case uses `core.output(...)`. The bare calls therefore crashed with
* `ReferenceError: output is not defined` the moment `audit-open` ran.
*
* This test runs both invocations against a minimal temp project and asserts
* they exit successfully with non-empty stdout. It fails with the
* ReferenceError on any revision that still has the bare `output(...)` calls.
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
describe('audit-open — does not crash with ReferenceError (#2659)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject('gsd-bug-2659-');
});
afterEach(() => {
cleanup(tmpDir);
});
test('audit-open (text output) succeeds and produces stdout', () => {
const result = runGsdTools('audit-open', tmpDir);
assert.ok(
result.success,
`audit-open must not crash. stderr: ${result.error}`
);
assert.ok(
!/ReferenceError.*output is not defined/.test(result.error || ''),
`audit-open must not throw ReferenceError. stderr: ${result.error}`
);
assert.ok(
result.output && result.output.length > 0,
'audit-open must write a non-empty report to stdout'
);
});
test('audit-open --json succeeds and produces stdout', () => {
const result = runGsdTools(['audit-open', '--json'], tmpDir);
assert.ok(
result.success,
`audit-open --json must not crash. stderr: ${result.error}`
);
assert.ok(
!/ReferenceError.*output is not defined/.test(result.error || ''),
`audit-open --json must not throw ReferenceError. stderr: ${result.error}`
);
assert.ok(
result.output && result.output.length > 0,
'audit-open --json must write output to stdout'
);
let parsed;
assert.doesNotThrow(
() => { parsed = JSON.parse(result.output); },
'audit-open --json must emit valid JSON'
);
assert.ok(
parsed !== null && typeof parsed === 'object',
'audit-open --json must emit a JSON object or array'
);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-2911-audit-open-output-shape.test.cjs — consolidation epic #1969 (B2 #1971)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-2911-audit-open-output-shape (consolidation epic #1969 B2 #1971)", () => {
'use strict';
/**
* Regression test for #2911.
*
* Two bugs in the `audit-open` dispatch case in bin/gsd-tools.cjs:
*
* 1. Bare `output(...)` calls (only `core.output` is in scope) → ReferenceError.
* 2. Even after switching to `core.output(formatted, raw)`, the human-readable
* branch JSON-stringifies the formatted string because `core.output` only
* bypasses JSON encoding when called as `core.output(null, true, rawValue)`.
* Result: stdout contains `"━━━…\n Milestone Close: …\n…"` (a JSON string
* literal) instead of the rendered report.
*
* The shape assertions below catch both regressions structurally — never via
* substring matching on serialized output:
*
* - text mode: parse stdout as a sequence of lines and assert the expected
* section headers exist as standalone lines (i.e. raw text, not escaped).
* If the report is JSON-stringified, the stdout is a single line wrapped
* in double quotes with `\n` escapes — line-array assertions fail.
* - --json mode: JSON.parse the stdout and assert the keys returned by
* `auditOpenArtifacts(cwd)` (scanned_at, has_open_items, counts, items)
* are present and well-typed.
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
describe('audit-open — output shape (#2911)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject('gsd-bug-2911-');
});
afterEach(() => {
cleanup(tmpDir);
});
test('text mode emits the formatted report as raw text (not JSON-encoded)', () => {
const result = runGsdTools('audit-open', tmpDir);
assert.ok(
result.success,
`audit-open must not crash. stderr: ${result.error}`
);
const lines = result.output.split('\n').map(l => l.trim()).filter(Boolean);
// The first non-empty line must be the divider character row, *not* a
// JSON-encoded string starting with a quote. If core.output JSON-stringified
// the formatted report, the entire payload sits on one line wrapped in
// double quotes ("━━━…\n…").
assert.ok(
!result.output.startsWith('"'),
'text-mode stdout must not begin with a JSON quote (would mean the report was JSON.stringified)'
);
assert.ok(
!result.output.includes('\\n'),
'text-mode stdout must not contain literal "\\n" sequences (would mean the report was JSON.stringified)'
);
// Section headers from formatAuditReport that must appear as standalone lines.
assert.ok(
lines.includes('Milestone Close: Open Artifact Audit'),
`expected report title as a standalone line; got lines: ${JSON.stringify(lines.slice(0, 5))}`
);
assert.ok(
lines.includes('All artifact types clear. Safe to proceed.'),
`expected the empty-state line as standalone text; got lines: ${JSON.stringify(lines)}`
);
});
test('--json mode emits parseable JSON matching auditOpenArtifacts shape', () => {
const result = runGsdTools(['audit-open', '--json'], tmpDir);
assert.ok(
result.success,
`audit-open --json must not crash. stderr: ${result.error}`
);
let parsed;
assert.doesNotThrow(
() => { parsed = JSON.parse(result.output); },
'audit-open --json must emit valid JSON (not a doubly-stringified string)'
);
assert.equal(typeof parsed, 'object', 'parsed payload must be an object');
assert.ok(parsed !== null, 'parsed payload must not be null');
// Shape contract from auditOpenArtifacts() in gsd-core/bin/lib/audit.cjs.
assert.equal(typeof parsed.scanned_at, 'string', 'must include scanned_at ISO timestamp');
assert.equal(typeof parsed.has_open_items, 'boolean', 'must include has_open_items boolean');
assert.equal(typeof parsed.counts, 'object', 'must include counts object');
assert.equal(typeof parsed.items, 'object', 'must include items object');
const expectedCountKeys = [
'debug_sessions', 'quick_tasks', 'threads', 'todos',
'seeds', 'uat_gaps', 'verification_gaps', 'context_questions', 'total',
];
for (const key of expectedCountKeys) {
assert.equal(
typeof parsed.counts[key], 'number',
`counts.${key} must be a number`
);
}
const expectedItemKeys = [
'debug_sessions', 'quick_tasks', 'threads', 'todos',
'seeds', 'uat_gaps', 'verification_gaps', 'context_questions',
];
for (const key of expectedItemKeys) {
assert.ok(
Array.isArray(parsed.items[key]),
`items.${key} must be an array`
);
}
});
});
});
}

View File

@@ -1,20 +0,0 @@
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { runGsdTools } = require('./helpers.cjs');
test('bug #167: query meta-command prefixes direct gsd-tools calls', () => {
const direct = runGsdTools(['init.progress']);
assert.equal(direct.success, true, `init.progress failed: ${direct.error || direct.output}`);
const meta = runGsdTools(['query', 'init.progress']);
assert.equal(meta.success, true, `query init.progress failed: ${meta.error || meta.output}`);
assert.deepEqual(
JSON.parse(meta.output),
JSON.parse(direct.output),
'query-prefixed and direct invocations should return identical init.progress payloads'
);
});

View File

@@ -1,77 +0,0 @@
'use strict';
// Regression test for issue #1760 — `state prune` no-ops on template-conformant
// STATE.md because it reads `Current Phase` only, never the `Phase: X of Y` line
// the canonical template emits.
//
// ADR-1769 Phase 7 fix: derive the current phase with a `Phase` / `Current Phase`
// fallback (mirroring buildStateFrontmatter), so prune engages on template STATE.md.
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
function writeStateMd(tmpDir, content) {
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content);
}
describe('#1760: state prune engages on template-conformant STATE.md (Phase: X of Y)', () => {
let tmpDir;
beforeEach(() => { tmpDir = createTempProject(); });
afterEach(() => { cleanup(tmpDir); });
test('prune engages when STATE.md uses "Phase: N of M" (no Current Phase field)', () => {
// Template-conformant STATE.md: Current Position uses `Phase: 10 of 15`, and
// there is NO `**Current Phase:**` body field. Pre-fix this made prune bail
// with "Only 0 phases — nothing to prune".
writeStateMd(tmpDir, [
'# Session State',
'',
'## Current Position',
'',
'Phase: 10 of 15',
'Plan: 2 of 4',
'Status: Executing Phase 10',
'',
'## Decisions',
'',
'- [Phase 1]: Old decision',
'- [Phase 3]: Older decision',
'- [Phase 9]: Recent decision',
'',
].join('\n'));
const result = runGsdTools('state prune --keep-recent 3 --dry-run', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
// Pre-fix: { pruned: false, reason: 'Only 0 phases — nothing to prune...' }.
// Post-fix: prune engages and reports a real cutoff_phase (10 - 3 = 7).
assert.strictEqual(out.pruned, false, 'dry-run must report pruned:false');
assert.ok(out.reason === undefined || !/Only 0 phases/i.test(String(out.reason)),
`prune must not bail with "Only 0 phases" on template STATE.md; got reason=${JSON.stringify(out.reason)}`);
assert.strictEqual(out.cutoff_phase, 7,
`cutoff_phase must be 7 (current 10 - keep-recent 3); got ${JSON.stringify(out.cutoff_phase)}`);
});
test('prune still engages when "Current Phase:" IS present (no regression)', () => {
writeStateMd(tmpDir, [
'# Session State',
'',
'**Current Phase:** 10',
'',
'## Decisions',
'',
'- [Phase 1]: Old decision',
'- [Phase 9]: Recent decision',
'',
].join('\n'));
const result = runGsdTools('state prune --keep-recent 3 --dry-run', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.cutoff_phase, 7);
});
});

View File

@@ -1,202 +0,0 @@
'use strict';
// Regression test for issue #1761 — `state sync` silently writes wrong progress
// when ROADMAP lacks versioned milestone headings.
//
// ADR-1769 Phase 7 fix: when getMilestonePhaseFilter().missingExplicitVersion is
// true (the current milestone cannot be bounded to a versioned phase set), the
// sync transition leaves Progress untouched (percent=null) rather than silently
// computing/writing values off a fallback milestone.
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
function buildStateWithProgress({ percent = 50 } = {}) {
const barWidth = 10;
const filled = Math.round((percent / 100) * barWidth);
const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
return [
'---',
'gsd_state_version: 1.0',
'milestone: v1.0',
'milestone_name: Test',
'current_phase: "3"',
'status: executing',
'progress:',
' total_phases: 10',
' completed_phases: 5',
` percent: ${percent}`,
'---',
'',
'# GSD State',
'',
'**Current Phase:** 3',
'**Total Plans in Phase:** 4',
'**Current Plan:** 2',
'**Status:** Executing Phase 3',
'**Last Activity:** 2026-06-20',
`**Progress:** [${bar}] ${percent}%`,
'',
].join('\n');
}
// ROADMAP with an UNVERSIONED milestone heading (no vX.Y) — the #1761 trigger.
function buildUnversionedRoadmap(numPhases) {
const lines = ['# ROADMAP', '', '## Milestone 1: Test Milestone', ''];
for (let i = 1; i <= numPhases; i++) {
lines.push(`### Phase ${i}: phase-${i}`);
lines.push('');
}
return lines.join('\n');
}
function readBodyProgress(statePath) {
const m = fs.readFileSync(statePath, 'utf-8').match(/\*\*Progress:\*\*\s*(.*)/);
return m ? m[1].trim() : null;
}
describe('#1761: state sync leaves Progress untouched when milestone is unbounded', () => {
let tmpDir;
beforeEach(() => { tmpDir = createTempProject(); });
afterEach(() => { cleanup(tmpDir); });
test('sync does NOT rewrite the Progress bar when ROADMAP lacks a versioned milestone heading', () => {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, buildStateWithProgress({ percent: 50 }));
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), buildUnversionedRoadmap(10));
// Seed disk: 2 of 10 phases fully summarized → if sync naively recomputes,
// it would write ~20%, clobbering the curated 50% (#1761).
const phasesDir = path.join(tmpDir, '.planning', 'phases');
for (let i = 1; i <= 2; i++) {
const dir = path.join(phasesDir, String(i).padStart(2, '0'));
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, '01-PLAN.md'), '# Plan\n');
fs.writeFileSync(path.join(dir, '01-SUMMARY.md'), '# Summary\n');
}
const before = readBodyProgress(statePath);
const result = runGsdTools('state sync', tmpDir);
assert.ok(result.success, `state sync failed: ${result.error}`);
const after = readBodyProgress(statePath);
assert.strictEqual(after, before,
`Progress must be left untouched when the milestone is unbounded; before=${JSON.stringify(before)} after=${JSON.stringify(after)} (#1761)`);
});
});
// #1761 read-path: the ADR-1769 Phase 7 fix (#1794) closed the `state sync`
// WRITE path, but `state json` (the READ path) rebuilds progress via
// buildStateFrontmatter, whose roadmapPhaseCount loop counts phase headings
// across the WHOLE document when extractCurrentMilestone can't bound the
// asserted milestone. Result: state json reported a conflated total_phases
// (sum of sibling milestones) + a derived percent, contradicting the sync
// guard. This block mirrors the write-path guard on the read path.
describe('#1761 read-path: state json does not conflate progress when milestone is unbounded', () => {
let tmpDir;
beforeEach(() => { tmpDir = createTempProject(); });
afterEach(() => { cleanup(tmpDir); });
test('state json omits percent and does NOT report the conflated whole-doc total_phases', () => {
// Repro from the issue: STATE.md asserts milestone: v2.0; ROADMAP has two
// UNVERSIONED sibling milestones (4 + 4 phases) — neither matches v2.0, so
// the milestone is unbounded. One summarized phase dir on disk.
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, [
'---',
'gsd_state_version: 1.0',
'milestone: v2.0',
'milestone_name: Second',
'current_phase: "2"',
'status: executing',
'---',
'',
'# GSD State',
'**Current Phase:** 2',
'**Status:** Executing Phase 2',
'',
].join('\n'));
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), [
'# ROADMAP',
'## Milestone 1: First Milestone',
'### Phase 1: a',
'### Phase 2: b',
'### Phase 3: c',
'### Phase 4: d',
'## Milestone 2: Second Milestone',
'### Phase 5: e',
'### Phase 6: f',
'### Phase 7: g',
'### Phase 8: h',
'',
].join('\n'));
// One summarized phase dir on disk.
const dir01 = path.join(tmpDir, '.planning', 'phases', '01');
fs.mkdirSync(dir01, { recursive: true });
fs.writeFileSync(path.join(dir01, '01-PLAN.md'), '# Plan\n');
fs.writeFileSync(path.join(dir01, '01-SUMMARY.md'), '# Summary\n');
const result = runGsdTools('state json --raw', tmpDir);
assert.ok(result.success, `state json failed: ${result.error}`);
const out = JSON.parse(result.output);
// BEFORE the fix this printed progress.total_phases: 8 (4+4 sibling
// milestones) and percent: 13 — exactly the conflated read-path the sync
// guard was added to prevent.
assert.ok(
out.progress === undefined || out.progress.percent === undefined,
`state json must omit percent when the milestone is unbounded; got progress=${JSON.stringify(out.progress)}`,
);
assert.ok(
!(out.progress && out.progress.total_phases === 8),
`state json must NOT report the conflated whole-doc total_phases (8 = 4+4 sibling milestones); got total_phases=${out.progress && out.progress.total_phases}`,
);
});
test('state json still reports percent + total_phases when the milestone IS bounded (versioned ROADMAP)', () => {
// Control: a versioned ROADMAP heading matching the asserted milestone
// keeps the read path unchanged — the guard only fires when unbounded.
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, [
'---',
'gsd_state_version: 1.0',
'milestone: v1.0',
'milestone_name: First',
'current_phase: "1"',
'status: executing',
'---',
'',
'# GSD State',
'**Current Phase:** 1',
'**Status:** Executing Phase 1',
'',
].join('\n'));
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), [
'# ROADMAP',
'## Milestone 1: First Milestone v1.0',
'### Phase 1: a',
'### Phase 2: b',
'',
].join('\n'));
const dir01 = path.join(tmpDir, '.planning', 'phases', '01');
fs.mkdirSync(dir01, { recursive: true });
fs.writeFileSync(path.join(dir01, '01-PLAN.md'), '# Plan\n');
fs.writeFileSync(path.join(dir01, '01-SUMMARY.md'), '# Summary\n');
const result = runGsdTools('state json --raw', tmpDir);
assert.ok(result.success, `state json failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.ok(
out.progress && typeof out.progress.percent === 'number',
`state json must report a numeric percent when the milestone is bounded; got progress=${JSON.stringify(out.progress)}`,
);
assert.strictEqual(
out.progress.total_phases,
2,
'bounded read path must report the versioned milestone phase count (2)',
);
});
});

View File

@@ -1,119 +0,0 @@
/**
* Regression test for bug #1818, updated for #3019.
*
* Original #1818 invariant: gsd-tools must NOT silently ignore --help/-h
* and proceed with a destructive command — that turned AI-agent
* hallucinations into accidental data loss (e.g. `phases clear --help`
* deleting phase dirs because the flag was dropped).
*
* #3019 update: the same destructive-protection invariant still holds,
* but the response shape changed. Previously --help → non-zero error
* exit. Now --help → render top-level usage and exit 0 WITHOUT running
* the command. Both shapes satisfy the original invariant ("the
* destructive command did not execute"); the new shape also restores
* subcommand discoverability for `gsd-sdk query <subcommand> --help`.
*
* The tests therefore assert two things:
* 1. The destructive command did NOT run (anti-hallucination invariant).
* 2. The output contains the top-level usage (#3019 discoverability).
*
* --version remains rejected — it's never a valid gsd-tools flag and has
* no discovery use-case.
*/
'use strict';
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { runGsdTools, createTempProject, cleanup, isUsageOutput } = require('./helpers.cjs');
describe('unknown flag guard (bug #1818, updated for #3019)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
// ── --help renders usage and does NOT run the destructive command ────────
test('phases clear --help renders usage and does NOT clear phase dirs', () => {
// Create a sentinel phase dir so we can assert it survives.
const phaseDir = path.join(tmpDir, '.planning', 'phases', 'phase-99');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, 'PLAN.md'), 'sentinel');
const result = runGsdTools(['phases', 'clear', '--help'], tmpDir);
assert.strictEqual(result.success, true, 'help renders, no error exit');
assert.ok(isUsageOutput(result.output), `expected top-level usage, got: ${result.output}`);
// Anti-hallucination invariant: the destructive command did NOT run.
assert.ok(fs.existsSync(phaseDir), 'phase dir must survive — clear must not have executed');
assert.ok(fs.existsSync(path.join(phaseDir, 'PLAN.md')));
});
test('generate-slug hello --help renders usage and does NOT emit a slug', () => {
const ok = runGsdTools(['generate-slug', 'hello'], tmpDir);
assert.strictEqual(ok.success, true, 'control: generate-slug works without --help');
// The control output is just the slug; the help output is the usage.
const slugOut = ok.output;
assert.ok(slugOut && !isUsageOutput(slugOut), `control should not be usage: ${slugOut}`);
const result = runGsdTools(['generate-slug', 'hello', '--help'], tmpDir);
assert.strictEqual(result.success, true);
assert.ok(isUsageOutput(result.output), 'help renders top-level usage');
assert.notEqual(result.output, slugOut, 'help output must differ from the slug — generate-slug must not have run');
});
test('phase complete --help renders usage and does NOT mark a phase complete', () => {
const result = runGsdTools(['phase', 'complete', '--help'], tmpDir);
assert.strictEqual(result.success, true);
assert.ok(isUsageOutput(result.output));
// success:true + isUsageOutput is sufficient: if the destructive path
// had executed it would have emitted a phase-resolution error to stderr
// (success:false), not the usage to stdout (success:true).
});
test('state load --help renders usage', () => {
const result = runGsdTools(['state', 'load', '--help'], tmpDir);
assert.strictEqual(result.success, true);
assert.ok(isUsageOutput(result.output));
});
// ── -h shorthand: same shape ─────────────────────────────────────────────
test('phases clear -h renders usage and does NOT clear phase dirs', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', 'phase-42');
fs.mkdirSync(phaseDir, { recursive: true });
const result = runGsdTools(['phases', 'clear', '-h'], tmpDir);
assert.strictEqual(result.success, true);
assert.ok(isUsageOutput(result.output));
assert.ok(fs.existsSync(phaseDir), 'phase dir must survive');
});
test('generate-slug hello -h renders usage', () => {
const result = runGsdTools(['generate-slug', 'hello', '-h'], tmpDir);
assert.strictEqual(result.success, true);
assert.ok(isUsageOutput(result.output));
});
// ── --version is still rejected — no discovery use-case ──────────────────
test('generate-slug hello --version is rejected', () => {
const result = runGsdTools(['generate-slug', 'hello', '--version'], tmpDir);
assert.strictEqual(result.success, false);
assert.match(result.error, /--version/);
});
// ── current-timestamp --help: same as the others ─────────────────────────
test('current-timestamp --help renders usage', () => {
const result = runGsdTools(['current-timestamp', '--help'], tmpDir);
assert.strictEqual(result.success, true);
assert.ok(isUsageOutput(result.output));
});
});

View File

@@ -1,72 +0,0 @@
/**
* Regression tests for bug #1826
*
* `phases clear` must require an explicit --confirm flag before deleting any
* phase directories. Without it, any accidental or hallucinated invocation
* wipes the entire .planning/phases/ tree with no warning.
*
* Rules:
* - Phase dirs present + no --confirm → non-zero exit, clear error message
* - Phase dirs present + --confirm → deletes, exits 0, reports count
* - No phase dirs + no --confirm → exits 0, cleared=0 (nothing to guard)
*/
'use strict';
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
describe('bug #1826: phases clear --confirm guard', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('phases clear without --confirm is rejected when phase dirs exist', () => {
const phasesDir = path.join(tmpDir, '.planning', 'phases');
fs.mkdirSync(path.join(phasesDir, '01-foundation'), { recursive: true });
fs.mkdirSync(path.join(phasesDir, '02-api'), { recursive: true });
const result = runGsdTools(['phases', 'clear'], tmpDir);
assert.ok(!result.success, 'should exit non-zero when dirs exist and --confirm absent');
assert.ok(
result.error.includes('--confirm'),
`error message must mention --confirm; got: ${result.error}`
);
// Dirs must be untouched
assert.ok(fs.existsSync(path.join(phasesDir, '01-foundation')), 'dirs must not be deleted');
assert.ok(fs.existsSync(path.join(phasesDir, '02-api')), 'dirs must not be deleted');
});
test('phases clear --confirm deletes dirs and reports count', () => {
const phasesDir = path.join(tmpDir, '.planning', 'phases');
fs.mkdirSync(path.join(phasesDir, '01-foundation'), { recursive: true });
fs.mkdirSync(path.join(phasesDir, '02-api'), { recursive: true });
const result = runGsdTools(['phases', 'clear', '--confirm'], tmpDir);
assert.ok(result.success, `should succeed with --confirm: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.cleared, 2);
assert.ok(!fs.existsSync(path.join(phasesDir, '01-foundation')), 'dirs should be removed');
});
test('phases clear without --confirm succeeds when no phase dirs exist', () => {
// .planning/phases/ exists but is empty — nothing to guard
const result = runGsdTools(['phases', 'clear'], tmpDir);
assert.ok(result.success, `should succeed with empty phases dir: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.cleared, 0);
});
});

View File

@@ -1,40 +0,0 @@
// allow-test-rule: structural-implementation-guard
// Bug #224 is a platform-specific (Node 24 + Windows) flake in `--pick` where
// stdout interception can produce non-deterministic failures. We lock the seam
// contract structurally until we have a deterministic Windows reproduction
// harness in CI.
'use strict';
const { describe, test, before } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { runGsdTools } = require('./helpers.cjs');
const GSD_TOOLS_SRC = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
describe('bug #224: --pick stdout capture contract', () => {
let src;
before(() => {
src = fs.readFileSync(GSD_TOOLS_SRC, 'utf-8');
});
test('--pick output still succeeds for current-timestamp command', () => {
const result = runGsdTools(['current-timestamp', '--pick', 'timestamp']);
assert.strictEqual(result.success, true, result.error || 'expected command to succeed');
assert.match(result.output, /^\d{4}-\d{2}-\d{2}T/, 'expected ISO timestamp output');
});
test('stdout interception for fd=1 returns a byte count (never undefined)', () => {
const mainStart = src.indexOf('async function main()');
assert.ok(mainStart !== -1, 'main() must exist');
const mainSrc = src.slice(mainStart);
assert.ok(
mainSrc.includes('Buffer.byteLength(') || mainSrc.includes('return data.length'),
'stdout interception must return written-byte counts for fd=1 captures'
);
});
});

View File

@@ -1,112 +0,0 @@
/**
* Regression test for bug #2268
*
* cmdInitProgress used a sliding-window pattern that set is_next_to_discuss
* only on the FIRST undiscussed phase. Multiple independent undiscussed phases
* could not be discussed in parallel — the manager only ever recommended one
* discuss action at a time.
*
* Fix: mark ALL undiscussed phases as is_next_to_discuss = true so the user
* can pick any of them.
*/
'use strict';
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
function writeRoadmap(tmpDir, phases) {
const sections = phases.map(p => {
let section = `### Phase ${p.number}: ${p.name}\n\n**Goal:** Do the thing\n`;
return section;
}).join('\n');
const checklist = phases.map(p => {
const mark = p.complete ? 'x' : ' ';
return `- [${mark}] **Phase ${p.number}: ${p.name}**`;
}).join('\n');
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n## Progress\n\n${checklist}\n\n${sections}`
);
}
function writeState(tmpDir) {
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '---\nstatus: active\n---\n# State\n');
}
let tmpDir;
describe('bug #2268: parallel discuss — all undiscussed phases marked is_next_to_discuss', () => {
beforeEach(() => { tmpDir = createTempProject(); });
afterEach(() => { cleanup(tmpDir); });
test('two undiscussed phases: both marked is_next_to_discuss', () => {
writeState(tmpDir);
writeRoadmap(tmpDir, [
{ number: '1', name: 'Foundation' },
{ number: '2', name: 'Cloud Deployment' },
]);
const result = runGsdTools('init manager', tmpDir);
const output = JSON.parse(result.output);
assert.strictEqual(output.phases[0].is_next_to_discuss, true, 'phase 1 should be discussable');
assert.strictEqual(output.phases[1].is_next_to_discuss, true, 'phase 2 should also be discussable');
});
test('two undiscussed phases: both get discuss recommendations', () => {
writeState(tmpDir);
writeRoadmap(tmpDir, [
{ number: '1', name: 'Foundation' },
{ number: '2', name: 'Cloud Deployment' },
]);
const result = runGsdTools('init manager', tmpDir);
const output = JSON.parse(result.output);
const discussActions = output.recommended_actions.filter(a => a.action === 'discuss');
assert.strictEqual(discussActions.length, 2, 'should recommend discuss for both undiscussed phases');
const phases = discussActions.map(a => a.phase).sort();
assert.deepStrictEqual(phases, ['1', '2']);
});
test('five undiscussed phases: all five marked is_next_to_discuss', () => {
writeState(tmpDir);
writeRoadmap(tmpDir, [
{ number: '1', name: 'Alpha' },
{ number: '2', name: 'Beta' },
{ number: '3', name: 'Gamma' },
{ number: '4', name: 'Delta' },
{ number: '5', name: 'Epsilon' },
]);
const result = runGsdTools('init manager', tmpDir);
const output = JSON.parse(result.output);
for (const phase of output.phases) {
assert.strictEqual(phase.is_next_to_discuss, true, `phase ${phase.number} should be discussable`);
}
});
test('discussed phase stays false; undiscussed sibling is true', () => {
writeState(tmpDir);
writeRoadmap(tmpDir, [
{ number: '1', name: 'Foundation' },
{ number: '2', name: 'API Layer' },
]);
// scaffold CONTEXT.md to mark phase 1 as discussed
const dir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, '01-CONTEXT.md'), '# Context');
const result = runGsdTools('init manager', tmpDir);
const output = JSON.parse(result.output);
assert.strictEqual(output.phases[0].is_next_to_discuss, false, 'discussed phase must not be is_next_to_discuss');
assert.strictEqual(output.phases[1].is_next_to_discuss, true, 'undiscussed sibling must be is_next_to_discuss');
});
});

View File

@@ -1,49 +0,0 @@
'use strict';
/**
* Regression tests for bug #2601
*
* `config-set-model-profile inherit` (and `config-set model_profile inherit`)
* was rejected by the validator even though the runtime accepts 'inherit' as a
* valid model_profile value meaning "inherit from parent configuration".
*
* Root cause: VALID_PROFILES in model-profiles.cjs is derived from
* Object.keys(MODEL_PROFILES['gsd-planner']), which does not include 'inherit'.
* cmdConfigSetModelProfile() rejects any value not in VALID_PROFILES.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
describe('bug #2601: config-set-model-profile accepts inherit', () => {
test('config-set-model-profile inherit succeeds', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(['config-set-model-profile', 'inherit'], tmpDir);
assert.ok(result.success, `should accept inherit: ${result.error}`);
});
test('config-set model_profile inherit succeeds', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(['config-set', 'model_profile', 'inherit'], tmpDir);
assert.ok(result.success, `config-set model_profile inherit should succeed: ${result.error}`);
});
test('config-set-model-profile inherit writes inherit to config', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
runGsdTools(['config-set-model-profile', 'inherit'], tmpDir);
const getResult = runGsdTools(['config-get', 'model_profile'], tmpDir);
assert.ok(getResult.success, `config-get should succeed: ${getResult.error}`);
assert.strictEqual(JSON.parse(getResult.output), 'inherit');
});
test('config-set-model-profile still rejects truly invalid profiles', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(['config-set-model-profile', 'not-a-real-profile'], tmpDir);
assert.ok(!result.success, 'should reject invalid profiles');
});
});

View File

@@ -1,119 +0,0 @@
/**
* GSD Tools Tests — Bug #2630
*
* Regression guard: `state milestone-switch` resets STATE.md YAML frontmatter
* (milestone, milestone_name, status, progress.*) AND the `## Current Position`
* body in a single atomic write. Prior to the fix, the `/gsd:new-milestone`
* workflow rewrote the body but left the frontmatter pointing at the previous
* milestone, so every downstream reader (state.json, getMilestoneInfo, etc.)
* reported the stale milestone.
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
const STALE_STATE = `---
gsd_state_version: 1.0
milestone: v1.0
milestone_name: Foundation
status: completed
progress:
total_phases: 5
completed_phases: 5
total_plans: 12
completed_plans: 12
percent: 100
---
# Project State
## Current Position
Phase: 5 (Foundation) — COMPLETED
Plan: 3 of 3
Status: v1.0 milestone complete
Last activity: 2026-04-20 -- v1.0 shipped
## Accumulated Context
### Decisions
- [Phase 1]: Use Node 20
`;
describe('state milestone-switch (#2630)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
STALE_STATE,
'utf-8',
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n## v1.1 Notifications\n\n### Phase 6: Notify\n',
'utf-8',
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
'{}',
'utf-8',
);
});
afterEach(() => {
cleanup(tmpDir);
});
test('writes new milestone into frontmatter and resets progress + Current Position', () => {
const result = runGsdTools(
['state', 'milestone-switch', '--milestone', 'v1.1', '--name', 'Notifications'],
tmpDir,
);
assert.equal(result.success, true, result.error || result.output);
const after = fs.readFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'utf-8',
);
// Frontmatter reflects the NEW milestone — the core of bug #2630.
assert.match(after, /^milestone:\s*v1\.1\s*$/m, 'frontmatter milestone not switched');
assert.match(
after,
/^milestone_name:\s*Notifications\s*$/m,
'frontmatter milestone_name not switched',
);
assert.match(after, /^status:\s*planning\s*$/m, 'status not reset to planning');
// Progress counters reset to zero.
assert.match(after, /^\s*completed_phases:\s*0\s*$/m, 'completed_phases not reset');
assert.match(after, /^\s*completed_plans:\s*0\s*$/m, 'completed_plans not reset');
assert.match(after, /^\s*percent:\s*0\s*$/m, 'percent not reset');
// Body Current Position reset to the new-milestone template.
assert.match(after, /Status:\s*Defining requirements/, 'body Status not reset');
assert.match(
after,
/Phase:\s*Not started \(defining requirements\)/,
'body Phase not reset',
);
// Accumulated Context is preserved.
assert.match(after, /\[Phase 1\]:\s*Use Node 20/, 'Accumulated Context lost');
});
test('rejects missing --milestone', () => {
const result = runGsdTools(
['state', 'milestone-switch', '--name', 'Something'],
tmpDir,
);
// gsd-tools emits JSON with { error: ... } to stdout even on error paths.
const combined = (result.output || '') + (result.error || '');
assert.match(combined, /milestone required/i);
});
});

View File

@@ -1,73 +0,0 @@
'use strict';
/**
* Regression test for #2659.
*
* The `audit-open` dispatch case in bin/gsd-tools.cjs previously called bare
* `output(...)` on both the --json and text branches. `output` is never in
* local scope — the entire core module is imported as `const core`, so every
* other case uses `core.output(...)`. The bare calls therefore crashed with
* `ReferenceError: output is not defined` the moment `audit-open` ran.
*
* This test runs both invocations against a minimal temp project and asserts
* they exit successfully with non-empty stdout. It fails with the
* ReferenceError on any revision that still has the bare `output(...)` calls.
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
describe('audit-open — does not crash with ReferenceError (#2659)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject('gsd-bug-2659-');
});
afterEach(() => {
cleanup(tmpDir);
});
test('audit-open (text output) succeeds and produces stdout', () => {
const result = runGsdTools('audit-open', tmpDir);
assert.ok(
result.success,
`audit-open must not crash. stderr: ${result.error}`
);
assert.ok(
!/ReferenceError.*output is not defined/.test(result.error || ''),
`audit-open must not throw ReferenceError. stderr: ${result.error}`
);
assert.ok(
result.output && result.output.length > 0,
'audit-open must write a non-empty report to stdout'
);
});
test('audit-open --json succeeds and produces stdout', () => {
const result = runGsdTools(['audit-open', '--json'], tmpDir);
assert.ok(
result.success,
`audit-open --json must not crash. stderr: ${result.error}`
);
assert.ok(
!/ReferenceError.*output is not defined/.test(result.error || ''),
`audit-open --json must not throw ReferenceError. stderr: ${result.error}`
);
assert.ok(
result.output && result.output.length > 0,
'audit-open --json must write output to stdout'
);
let parsed;
assert.doesNotThrow(
() => { parsed = JSON.parse(result.output); },
'audit-open --json must emit valid JSON'
);
assert.ok(
parsed !== null && typeof parsed === 'object',
'audit-open --json must emit a JSON object or array'
);
});
});

View File

@@ -1,225 +0,0 @@
// allow-test-rule: source-text-is-the-product
// Reads .md/.json/.yml product files whose deployed text IS what the
// runtime loads — testing text content tests the deployed contract.
/**
* Regression tests for bug #2661:
* `/gsd-execute-phase N --auto` with parallelization: true, use_worktrees: false
* left ROADMAP plan checkboxes unchecked until a manual
* `roadmap update-plan-progress` was run.
*
* Root cause (workflow-level): execute-plan.md `update_roadmap` step was
* gated on a worktree-detection branch that incorrectly conflated
* "parallel mode" with "worktree mode". When `parallelization: true,
* use_worktrees: false` was configured, the step was still gated by the
* worktree-only check (which is true: the executing tree IS the main repo,
* not a worktree, so the gate happened to fire correctly there) — the
* actual reproducer was a different code path. The original PR #2682 fix
* made the sync unconditional, which violated the single-writer contract
* for shared ROADMAP.md established by #1486 / dcb50396 in worktree mode.
*
* Minimal fix (this PR): restore the worktree guard and document its
* intent explicitly. The `IS_WORKTREE != "true"` branch IS the
* `use_worktrees: false` mode: only that mode runs the in-handler sync.
* Worktree mode relies on the orchestrator's post-merge sync at
* execute-phase.md §5.7 (lines 815-834) — the single writer for shared
* tracking files.
*
* These tests:
* (1) assert the workflow gates the sync call on `use_worktrees: false`
* (i.e. the IS_WORKTREE != "true" branch is present and gates the call);
* (2) assert the handler itself behaves correctly under the
* use_worktrees: false reproducer (the original #2661 case);
* (3) assert the handler is idempotent and lock-safe (lockfile is the
* in-handler defense; the workflow gate is the cross-handler one).
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
const WORKFLOW_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'execute-plan.md');
function writeRoadmap(tmpDir, content) {
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), content);
}
function readRoadmap(tmpDir) {
return fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
}
function seedPhase(tmpDir, phaseNum, planIds, summaryIds) {
const phaseDir = path.join(tmpDir, '.planning', 'phases', `${String(phaseNum).padStart(2, '0')}-test`);
fs.mkdirSync(phaseDir, { recursive: true });
for (const id of planIds) {
fs.writeFileSync(path.join(phaseDir, `${id}-PLAN.md`), `# Plan ${id}`);
}
for (const id of summaryIds) {
fs.writeFileSync(path.join(phaseDir, `${id}-SUMMARY.md`), `# Summary ${id}`);
}
}
const THREE_PLAN_ROADMAP = `# Roadmap
- [ ] Phase 1: Test phase with three parallel plans
- [ ] 01-01-PLAN.md
- [ ] 01-02-PLAN.md
- [ ] 01-03-PLAN.md
### Phase 1: Test
**Goal:** Parallel execution regression test
**Plans:** 3 plans
## Progress
| Phase | Milestone | Plans Complete | Status | Completed |
|-------|-----------|----------------|--------|-----------|
| 1. Test | v1.0 | 0/3 | Planned | |
`;
// ─── Structural: workflow gates sync on use_worktrees=false ──────────────────
describe('bug #2661: execute-plan.md update_roadmap gating', () => {
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
const stepMatch = content.match(
/<step name="update_roadmap">([\s\S]*?)<\/step>/
);
const step = stepMatch && stepMatch[1];
test('update_roadmap step exists and invokes roadmap.update-plan-progress', () => {
assert.ok(stepMatch, 'update_roadmap step must exist');
// After #3797 architectural fix, callsites use gsd_run
assert.ok(
/gsd_run query roadmap\.update-plan-progress/.test(step),
'update_roadmap must still invoke roadmap.update-plan-progress'
);
});
test('use_worktrees: false mode — sync call is gated to fire (the #2661 reproducer)', () => {
// The non-worktree branch must contain the sync call.
// After #3797 architectural fix, callsites use gsd_run
assert.ok(
/IS_WORKTREE.*!=.*"true"[\s\S]*?gsd_run query roadmap\.update-plan-progress/.test(step),
'sync call must execute on the IS_WORKTREE != "true" branch (use_worktrees: false)'
);
});
test('use_worktrees: true mode — sync call does NOT fire (single-writer contract)', () => {
// The sync call must be inside an `if [ "$IS_WORKTREE" != "true" ]` block,
// i.e. it must NOT be unconditional and it must NOT appear on the worktree branch.
// We verify by extracting the bash block and checking the call sits under the gate.
const bashMatch = step.match(/```bash\s*([\s\S]*?)```/);
assert.ok(bashMatch, 'update_roadmap must contain a bash block');
const bash = bashMatch[1];
assert.ok(
/IS_WORKTREE/.test(bash),
'bash block must include the IS_WORKTREE worktree-detection check'
);
// Sync call must appear after the guard check, not before.
// After #3797 architectural fix, callsites use gsd_run
const guardIdx = bash.search(/if \[ "\$IS_WORKTREE" != "true" \]/);
const callIdx = bash.search(/gsd_run query roadmap\.update-plan-progress/);
assert.ok(guardIdx >= 0, 'guard must be present');
assert.ok(callIdx > guardIdx,
'sync call must appear inside the use_worktrees: false guard, not before/outside it');
});
test('intent doc references single-writer contract / orchestrator-owns-write', () => {
// The prose must justify why worktree mode is excluded so future readers
// do not regress this back to unconditional.
assert.ok(
/worktree|orchestrator|single-writer|#1486|#2661/i.test(step),
'update_roadmap must document the contract that justifies the gate'
);
});
});
// ─── Handler-level: idempotence + multi-plan sync (use_worktrees: false case) ─
describe('bug #2661: roadmap update-plan-progress handler (use_worktrees: false)', () => {
let tmpDir;
beforeEach(() => { tmpDir = createTempProject('gsd-2661-'); });
afterEach(() => { cleanup(tmpDir); });
test('three parallel SUMMARY.md files produce three [x] plan checkboxes', () => {
writeRoadmap(tmpDir, THREE_PLAN_ROADMAP);
seedPhase(tmpDir, 1, ['01-01', '01-02', '01-03'], ['01-01', '01-02', '01-03']);
const result = runGsdTools('roadmap update-plan-progress 1', tmpDir);
assert.ok(result.success, `handler failed: ${result.error}`);
const roadmap = readRoadmap(tmpDir);
assert.ok(roadmap.includes('[x] 01-01-PLAN.md'), 'plan 01-01 should be checked');
assert.ok(roadmap.includes('[x] 01-02-PLAN.md'), 'plan 01-02 should be checked');
assert.ok(roadmap.includes('[x] 01-03-PLAN.md'), 'plan 01-03 should be checked');
assert.ok(roadmap.includes('3/3'), 'progress row should reflect 3/3');
});
test('handler is idempotent — second call produces identical content', () => {
writeRoadmap(tmpDir, THREE_PLAN_ROADMAP);
seedPhase(tmpDir, 1, ['01-01', '01-02', '01-03'], ['01-01', '01-02', '01-03']);
const first = runGsdTools('roadmap update-plan-progress 1', tmpDir);
assert.ok(first.success, first.error);
const afterFirst = readRoadmap(tmpDir);
const second = runGsdTools('roadmap update-plan-progress 1', tmpDir);
assert.ok(second.success, second.error);
const afterSecond = readRoadmap(tmpDir);
assert.strictEqual(afterSecond, afterFirst,
'repeated invocation must not mutate ROADMAP.md further (idempotent)');
});
test('partial completion: only plans with SUMMARY.md get [x]', () => {
writeRoadmap(tmpDir, THREE_PLAN_ROADMAP);
// Only plan 01-02 has a SUMMARY.md
seedPhase(tmpDir, 1, ['01-01', '01-02', '01-03'], ['01-02']);
const result = runGsdTools('roadmap update-plan-progress 1', tmpDir);
assert.ok(result.success, result.error);
const roadmap = readRoadmap(tmpDir);
assert.ok(roadmap.includes('[ ] 01-01-PLAN.md'), 'plan 01-01 should remain unchecked');
assert.ok(roadmap.includes('[x] 01-02-PLAN.md'), 'plan 01-02 should be checked');
assert.ok(roadmap.includes('[ ] 01-03-PLAN.md'), 'plan 01-03 should remain unchecked');
assert.ok(roadmap.includes('1/3'), 'progress row should reflect 1/3');
});
test('lockfile contention: concurrent handler invocations within a single tree do not corrupt ROADMAP.md', async () => {
// Scope: lockfile only serializes within a single working tree. Cross-worktree
// serialization is enforced by the workflow gate (worktree mode never calls
// this handler from execute-plan.md), not by the lockfile.
writeRoadmap(tmpDir, THREE_PLAN_ROADMAP);
seedPhase(tmpDir, 1, ['01-01', '01-02', '01-03'], ['01-01', '01-02', '01-03']);
const invocations = Array.from({ length: 3 }, () =>
new Promise((resolve) => {
const r = runGsdTools('roadmap update-plan-progress 1', tmpDir);
resolve(r);
})
);
const results = await Promise.all(invocations);
for (const r of results) {
assert.ok(r.success, `concurrent handler invocation failed: ${r.error}`);
}
const roadmap = readRoadmap(tmpDir);
// Structural integrity: each checkbox appears exactly once, progress row intact.
for (const id of ['01-01', '01-02', '01-03']) {
const occurrences = roadmap.split(`[x] ${id}-PLAN.md`).length - 1;
assert.strictEqual(occurrences, 1,
`plan ${id} checkbox should appear exactly once (got ${occurrences})`);
}
assert.ok(roadmap.includes('3/3'), 'progress row should reflect 3/3 after concurrent runs');
// Lockfile should have been cleaned up after the final release.
assert.ok(
!fs.existsSync(path.join(tmpDir, '.planning', 'ROADMAP.md.lock')),
'ROADMAP.md.lock should be released after concurrent invocations settle'
);
});
});

View File

@@ -1,121 +0,0 @@
/**
* Regression tests for issue #2769
*
* The Requirements header in ROADMAP.md phase blocks renders identically in
* markdown for three textually distinct forms:
*
* **Requirements:** colon INSIDE bold delimiters
* **Requirements**: colon OUTSIDE bold delimiters
* **Requirements** : space-then-colon outside bold
*
* Two parsers in the codebase used opposing strict regexes — one only
* matched the outside-colon form (init.cjs / init.ts), the other only the
* inside-colon form (phase.cjs `cmdPhaseComplete` REQUIREMENTS.md
* traceability sweep). Both must accept all three variants so phase
* metadata propagation is robust to authoring style.
*
* Tests for the init query side live in `tests/init.test.cjs` (parameterized
* over the three variants). This file exercises the inverse bug in
* `phase complete`: the REQUIREMENTS.md checkbox must flip when ROADMAP
* uses the outside-colon form, which previously was silently skipped.
*/
'use strict';
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
describe('bug #2769: phase complete ticks REQUIREMENTS.md across header variants', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
['---', 'current_phase: 1', 'status: executing', '---', '# State', ''].join('\n'),
);
});
afterEach(() => {
cleanup(tmpDir);
});
const headerVariants = [
{ name: 'colon inside bold (**Requirements:**)', header: '**Requirements:** REQ-001' },
{ name: 'colon outside bold (**Requirements**:)', header: '**Requirements**: REQ-001' },
{ name: 'space before colon (**Requirements** :)', header: '**Requirements** : REQ-001' },
];
for (const variant of headerVariants) {
test(`flips REQ-001 checkbox in REQUIREMENTS.md when ROADMAP uses ${variant.name}`, () => {
const phasesDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
fs.mkdirSync(phasesDir, { recursive: true });
fs.writeFileSync(
path.join(phasesDir, '01-1-PLAN.md'),
['---', 'phase: 1', 'plan: 1', '---', '# Plan 1', ''].join('\n'),
);
fs.writeFileSync(
path.join(phasesDir, '01-1-SUMMARY.md'),
['---', 'status: complete', '---', '# Summary', 'Done.'].join('\n'),
);
fs.writeFileSync(
path.join(phasesDir, '01-VERIFICATION.md'),
['---', 'status: passed', 'score: "1/1"', '---', '# Verification', 'Passed.'].join('\n'),
);
const roadmap = [
'# Roadmap',
'',
'### Phase 1: Foundation',
'',
'**Goal:** Build core',
variant.header,
'**Plans:** 1 plans',
'',
'Plans:',
'- [x] 01-1-PLAN.md',
'',
'| Phase | Plans | Status | Completed |',
'|-------|-------|--------|-----------|',
'| 1. Foundation | 0/1 | Pending | - |',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
const requirements = [
'# Requirements',
'',
'## Functional Requirements',
'',
'- [ ] **REQ-001**: Core data model',
'',
'## Traceability',
'',
'| REQ-ID | Phase | Status |',
'|--------|-------|--------|',
'| REQ-001 | 1 | Pending |',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), requirements);
const result = runGsdTools(['phase', 'complete', '1'], tmpDir);
assert.ok(result.success, `phase complete failed: ${result.error}`);
const updated = fs.readFileSync(
path.join(tmpDir, '.planning', 'REQUIREMENTS.md'),
'utf-8',
);
assert.match(
updated,
/-\s*\[x\]\s*\*\*REQ-001\*\*/,
`REQ-001 checkbox must be flipped to [x] when ROADMAP header is "${variant.header}". Got:\n${updated}`,
);
assert.match(
updated,
/\|\s*REQ-001\s*\|\s*1\s*\|\s*Complete\s*\|/,
`Traceability row for REQ-001 must be marked Complete. Got:\n${updated}`,
);
});
}
});

View File

@@ -1,120 +0,0 @@
'use strict';
/**
* Regression test for #2911.
*
* Two bugs in the `audit-open` dispatch case in bin/gsd-tools.cjs:
*
* 1. Bare `output(...)` calls (only `core.output` is in scope) → ReferenceError.
* 2. Even after switching to `core.output(formatted, raw)`, the human-readable
* branch JSON-stringifies the formatted string because `core.output` only
* bypasses JSON encoding when called as `core.output(null, true, rawValue)`.
* Result: stdout contains `"━━━…\n Milestone Close: …\n…"` (a JSON string
* literal) instead of the rendered report.
*
* The shape assertions below catch both regressions structurally — never via
* substring matching on serialized output:
*
* - text mode: parse stdout as a sequence of lines and assert the expected
* section headers exist as standalone lines (i.e. raw text, not escaped).
* If the report is JSON-stringified, the stdout is a single line wrapped
* in double quotes with `\n` escapes — line-array assertions fail.
* - --json mode: JSON.parse the stdout and assert the keys returned by
* `auditOpenArtifacts(cwd)` (scanned_at, has_open_items, counts, items)
* are present and well-typed.
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
describe('audit-open — output shape (#2911)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject('gsd-bug-2911-');
});
afterEach(() => {
cleanup(tmpDir);
});
test('text mode emits the formatted report as raw text (not JSON-encoded)', () => {
const result = runGsdTools('audit-open', tmpDir);
assert.ok(
result.success,
`audit-open must not crash. stderr: ${result.error}`
);
const lines = result.output.split('\n').map(l => l.trim()).filter(Boolean);
// The first non-empty line must be the divider character row, *not* a
// JSON-encoded string starting with a quote. If core.output JSON-stringified
// the formatted report, the entire payload sits on one line wrapped in
// double quotes ("━━━…\n…").
assert.ok(
!result.output.startsWith('"'),
'text-mode stdout must not begin with a JSON quote (would mean the report was JSON.stringified)'
);
assert.ok(
!result.output.includes('\\n'),
'text-mode stdout must not contain literal "\\n" sequences (would mean the report was JSON.stringified)'
);
// Section headers from formatAuditReport that must appear as standalone lines.
assert.ok(
lines.includes('Milestone Close: Open Artifact Audit'),
`expected report title as a standalone line; got lines: ${JSON.stringify(lines.slice(0, 5))}`
);
assert.ok(
lines.includes('All artifact types clear. Safe to proceed.'),
`expected the empty-state line as standalone text; got lines: ${JSON.stringify(lines)}`
);
});
test('--json mode emits parseable JSON matching auditOpenArtifacts shape', () => {
const result = runGsdTools(['audit-open', '--json'], tmpDir);
assert.ok(
result.success,
`audit-open --json must not crash. stderr: ${result.error}`
);
let parsed;
assert.doesNotThrow(
() => { parsed = JSON.parse(result.output); },
'audit-open --json must emit valid JSON (not a doubly-stringified string)'
);
assert.equal(typeof parsed, 'object', 'parsed payload must be an object');
assert.ok(parsed !== null, 'parsed payload must not be null');
// Shape contract from auditOpenArtifacts() in gsd-core/bin/lib/audit.cjs.
assert.equal(typeof parsed.scanned_at, 'string', 'must include scanned_at ISO timestamp');
assert.equal(typeof parsed.has_open_items, 'boolean', 'must include has_open_items boolean');
assert.equal(typeof parsed.counts, 'object', 'must include counts object');
assert.equal(typeof parsed.items, 'object', 'must include items object');
const expectedCountKeys = [
'debug_sessions', 'quick_tasks', 'threads', 'todos',
'seeds', 'uat_gaps', 'verification_gaps', 'context_questions', 'total',
];
for (const key of expectedCountKeys) {
assert.equal(
typeof parsed.counts[key], 'number',
`counts.${key} must be a number`
);
}
const expectedItemKeys = [
'debug_sessions', 'quick_tasks', 'threads', 'todos',
'seeds', 'uat_gaps', 'verification_gaps', 'context_questions',
];
for (const key of expectedItemKeys) {
assert.ok(
Array.isArray(parsed.items[key]),
`items.${key} must be an array`
);
}
});
});

View File

@@ -1,199 +0,0 @@
/**
* GSD Tools Tests — detect-custom-files misses skills/ directory (#2942)
*
* After v1.39.0 skill consolidation (#2790), skills/ became a GSD-managed root.
* GSD_MANAGED_DIRS was missing 'skills', so user-added GSD-prefixed skill
* directories like skills/gsd-custom-skill/SKILL.md were never walked and got
* silently destroyed during /gsd-update.
*/
'use strict';
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { runGsdTools, createTempDir, cleanup } = require('./helpers.cjs');
function sha256(content) {
return crypto.createHash('sha256').update(content).digest('hex');
}
/**
* Write a fake gsd-file-manifest.json into configDir with the given file entries.
* Each entry is also written to disk so the directory structure exists.
*/
function writeManifest(configDir, files) {
const manifest = {
version: '1.39.0',
timestamp: new Date().toISOString(),
files: {}
};
for (const [relPath, content] of Object.entries(files)) {
const fullPath = path.join(configDir, relPath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content);
manifest.files[relPath] = sha256(content);
}
fs.writeFileSync(
path.join(configDir, 'gsd-file-manifest.json'),
JSON.stringify(manifest, null, 2)
);
}
/**
* Write a file inside configDir (creating parent dirs), but do NOT add it to the manifest.
*/
function writeCustomFile(configDir, relPath, content) {
const fullPath = path.join(configDir, relPath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content);
}
describe('detect-custom-files — skills/ directory missing from GSD_MANAGED_DIRS (#2942)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempDir('gsd-2942-skills-');
});
afterEach(() => {
cleanup(tmpDir);
});
// Test 1: detects custom GSD-prefixed skill in skills/gsd-<name>/SKILL.md
test('detects custom skill file at skills/gsd-<name>/SKILL.md', () => {
writeManifest(tmpDir, {
'skills/gsd-planner/SKILL.md': '# GSD Planner Skill\n',
});
// User-added custom GSD-prefixed skill — NOT in manifest
writeCustomFile(tmpDir, 'skills/gsd-test-custom/SKILL.md', '# My Custom Skill\n');
const result = runGsdTools(
['detect-custom-files', '--config-dir', tmpDir],
tmpDir
);
assert.ok(result.success, `Command failed: ${result.error}`);
const json = JSON.parse(result.output);
assert.ok(Array.isArray(json.custom_files), 'custom_files should be an array');
assert.ok(json.custom_count >= 1, `custom_count should be >= 1, got ${json.custom_count}`);
assert.ok(
json.custom_files.includes('skills/gsd-test-custom/SKILL.md'),
`skills/gsd-test-custom/SKILL.md should be in custom_files; got: ${JSON.stringify(json.custom_files)}`
);
});
test('does not detect non-gsd shared skills preserved by installer (#1325)', () => {
writeManifest(tmpDir, {
'skills/gsd-planner/SKILL.md': '# GSD Planner Skill\n',
});
writeCustomFile(tmpDir, 'skills/test-custom/SKILL.md', '# My Custom Skill\n');
const result = runGsdTools(
['detect-custom-files', '--config-dir', tmpDir],
tmpDir
);
assert.ok(result.success, `Command failed: ${result.error}`);
const json = JSON.parse(result.output);
assert.ok(Array.isArray(json.custom_files), 'custom_files should be an array');
assert.ok(
!json.custom_files.includes('skills/test-custom/SKILL.md'),
`non-gsd shared skill should not be in custom_files; got: ${JSON.stringify(json.custom_files)}`
);
});
// Test 2: does not flag GSD-owned skills as custom (manifest-tracked path NOT in custom_files)
test('does not flag GSD-owned skill as custom when it is tracked in manifest', () => {
writeManifest(tmpDir, {
'skills/gsd-planner/SKILL.md': '# GSD Planner Skill\n',
});
// No extra files — only the manifest-tracked skill exists
const result = runGsdTools(
['detect-custom-files', '--config-dir', tmpDir],
tmpDir
);
assert.ok(result.success, `Command failed: ${result.error}`);
const json = JSON.parse(result.output);
assert.ok(Array.isArray(json.custom_files), 'custom_files should be an array');
assert.ok(
!json.custom_files.includes('skills/gsd-planner/SKILL.md'),
`GSD-owned skill should NOT be in custom_files; got: ${JSON.stringify(json.custom_files)}`
);
});
// Test 3: regression guard — still detects custom files in gsd-core/workflows/
test('regression: still detects custom files in gsd-core/workflows/', () => {
writeManifest(tmpDir, {
'gsd-core/workflows/plan-phase.md': '# Plan Phase\n',
'skills/gsd-planner/SKILL.md': '# GSD Planner Skill\n',
});
writeCustomFile(tmpDir, 'gsd-core/workflows/custom-workflow.md', '# My Custom Workflow\n');
const result = runGsdTools(
['detect-custom-files', '--config-dir', tmpDir],
tmpDir
);
assert.ok(result.success, `Command failed: ${result.error}`);
const json = JSON.parse(result.output);
assert.ok(
json.custom_files.includes('gsd-core/workflows/custom-workflow.md'),
`custom workflow should still be detected; got: ${JSON.stringify(json.custom_files)}`
);
});
// Test 4: custom_count matches custom_files.length
test('custom_count matches custom_files.length when multiple custom gsd-prefixed skills exist', () => {
writeManifest(tmpDir, {
'skills/gsd-planner/SKILL.md': '# GSD Planner Skill\n',
});
writeCustomFile(tmpDir, 'skills/gsd-test-custom/SKILL.md', '# Custom Skill One\n');
writeCustomFile(tmpDir, 'skills/gsd-another-custom/SKILL.md', '# Custom Skill Two\n');
const result = runGsdTools(
['detect-custom-files', '--config-dir', tmpDir],
tmpDir
);
assert.ok(result.success, `Command failed: ${result.error}`);
const json = JSON.parse(result.output);
assert.strictEqual(
json.custom_count,
json.custom_files.length,
`custom_count (${json.custom_count}) should equal custom_files.length (${json.custom_files.length})`
);
assert.strictEqual(json.custom_count, 2, 'should detect exactly 2 custom skill files');
});
// Test 5: manifest_found: true when manifest is present
test('manifest_found is true when manifest is present', () => {
writeManifest(tmpDir, {
'skills/gsd-planner/SKILL.md': '# GSD Planner Skill\n',
});
const result = runGsdTools(
['detect-custom-files', '--config-dir', tmpDir],
tmpDir
);
assert.ok(result.success, `Command failed: ${result.error}`);
const json = JSON.parse(result.output);
assert.strictEqual(json.manifest_found, true, 'manifest_found should be true');
});
});

View File

@@ -1,88 +0,0 @@
// allow-test-rule: workflow-markdown-is-the-runtime-contract
// Justification: complete-milestone.md IS the runtime — the agent reads and
// follows it directly. Asserting the <config-check> block is present in the
// markdown is the only way to verify the gate is wired. Per CONTEXT.md L611.
'use strict';
/**
* #3086 — git.create_tag config gate for milestone tagging.
*
* Tests:
* A. Default value: fresh project returns `true` for git.create_tag
* B. config-set false → config-get returns false
* C. Invalid value (e.g. "maybe") is rejected by schema validator
* D. complete-milestone.md workflow contains the <config-check> gate for git.create_tag
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
const WORKFLOW_PATH = path.join(
__dirname,
'..',
'gsd-core',
'workflows',
'complete-milestone.md',
);
describe('#3086: git.create_tag config key', () => {
test('A. fresh project: config-get git.create_tag returns true (default)', (t) => {
const tmpDir = createTempProject('gsd-3086-default-');
t.after(() => cleanup(tmpDir));
const result = runGsdTools(['config-get', 'git.create_tag'], tmpDir, { HOME: tmpDir });
assert.ok(result.success, `config-get git.create_tag failed:\n${result.error}`);
assert.strictEqual(
result.output.trim(),
'true',
`Expected default value 'true', got: '${result.output.trim()}'`,
);
});
test('B. config-set git.create_tag false → config-get returns false', (t) => {
const tmpDir = createTempProject('gsd-3086-set-false-');
t.after(() => cleanup(tmpDir));
const setResult = runGsdTools(['config-set', 'git.create_tag', 'false'], tmpDir, {
HOME: tmpDir,
});
assert.ok(setResult.success, `config-set git.create_tag false failed:\n${setResult.error}`);
const getResult = runGsdTools(['config-get', 'git.create_tag'], tmpDir, { HOME: tmpDir });
assert.ok(getResult.success, `config-get after set failed:\n${getResult.error}`);
assert.strictEqual(
getResult.output.trim(),
'false',
`Expected 'false' after set, got: '${getResult.output.trim()}'`,
);
});
test('C. config-set git.create_tag with invalid value "maybe" is rejected', (t) => {
const tmpDir = createTempProject('gsd-3086-invalid-');
t.after(() => cleanup(tmpDir));
const result = runGsdTools(['config-set', 'git.create_tag', 'maybe'], tmpDir, {
HOME: tmpDir,
});
assert.ok(
!result.success,
`Expected config-set to fail for invalid value "maybe", but it succeeded`,
);
});
test('D. complete-milestone.md contains <config-check> gate for git.create_tag', () => {
const content = fs.readFileSync(WORKFLOW_PATH, 'utf8');
assert.ok(
content.includes('git.create_tag'),
'complete-milestone.md must reference git.create_tag in a <config-check> block',
);
assert.ok(
content.includes('<config-check>'),
'complete-milestone.md must have a <config-check> block in the git_tag step',
);
});
});

View File

@@ -1,58 +0,0 @@
'use strict';
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
describe('bug #3150: stats.json includes contiguous decimal phases when .10 exists', () => {
test('stats json preserves 06.7/06.8/06.9 alongside 06.10', () => {
const tmpDir = createTempProject('gsd-bug-3150-');
try {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n### Phase 06.6: P06.6\n**Goal:** G\n\n### Phase 06.7: P06.7\n**Goal:** G\n\n### Phase 06.8: P06.8\n**Goal:** G\n\n### Phase 06.9: P06.9\n**Goal:** G\n\n### Phase 06.10: P06.10\n**Goal:** G\n`
);
const dirs = ['06.6-a', '06.7-b', '06.8-c', '06.9-d', '06.10-e'];
for (const dirName of dirs) {
const phaseDir = path.join(tmpDir, '.planning', 'phases', dirName);
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, 'PLAN.md'), '# plan\n');
fs.writeFileSync(path.join(phaseDir, 'SUMMARY.md'), '# summary\n');
}
const result = runGsdTools('stats json', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
let output;
assert.doesNotThrow(
() => {
output = JSON.parse(result.output);
},
`Command output must be valid JSON. Raw output prefix: ${result.output.slice(0, 200)}`
);
assert.ok(
output && typeof output === 'object' && !Array.isArray(output),
`Expected object output, got: ${typeof output}`
);
assert.ok(
Array.isArray(output.phases),
`Expected output.phases array. Raw output prefix: ${result.output.slice(0, 200)}`
);
const phaseNumbers = output.phases.map((p) => p.number);
assert.deepStrictEqual(
phaseNumbers,
['06.6', '06.7', '06.8', '06.9', '06.10'],
'stats.json must not skip 06.7/06.8/06.9 when 06.10 exists'
);
assert.equal(output.phases_total, 5);
assert.equal(output.total_plans, 5);
assert.equal(output.total_summaries, 5);
} finally {
cleanup(tmpDir);
}
});
});

View File

@@ -1,126 +0,0 @@
'use strict';
/**
* Bug #3163: generate-claude-md should write to AGENTS.md on Codex runtime.
*
* When config.runtime === 'codex' (or GSD_RUNTIME=codex), the generate-claude-md
* handler must resolve the output path to AGENTS.md, not CLAUDE.md.
*/
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
describe('bug #3163: generate-claude-md uses AGENTS.md for Codex runtime', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
fs.writeFileSync(
path.join(tmpDir, '.planning', 'PROJECT.md'),
'# Test Project\n\nA Codex-hosted project.\n',
'utf-8'
);
});
afterEach(() => {
cleanup(tmpDir);
});
test('writes to AGENTS.md when config.runtime is codex and no --output given', () => {
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(
configPath,
JSON.stringify({ runtime: 'codex', claude_md_path: './CLAUDE.md' }),
'utf-8'
);
const result = runGsdTools('generate-claude-md', tmpDir, { HOME: tmpDir });
assert.ok(result.success, `Command failed: ${result.error}`);
const parsed = JSON.parse(result.output);
const realTmpDir = fs.realpathSync(tmpDir);
const expectedAgentsPath = path.join(realTmpDir, 'AGENTS.md');
// The returned path must be AGENTS.md, not CLAUDE.md
assert.strictEqual(parsed.claude_md_path, expectedAgentsPath,
`Expected output path to be AGENTS.md but got: ${parsed.claude_md_path}`
);
// AGENTS.md must exist on disk
assert.ok(fs.existsSync(expectedAgentsPath), 'AGENTS.md must exist after generation');
// CLAUDE.md must NOT be created
assert.ok(!fs.existsSync(path.join(realTmpDir, 'CLAUDE.md')), 'CLAUDE.md must not be created for Codex runtime');
});
test('writes to AGENTS.md when GSD_RUNTIME=codex env var is set (env takes precedence over config)', () => {
const configPath = path.join(tmpDir, '.planning', 'config.json');
// Config says runtime: claude but env overrides to codex
fs.writeFileSync(
configPath,
JSON.stringify({ runtime: 'claude', claude_md_path: './CLAUDE.md' }),
'utf-8'
);
const result = runGsdTools('generate-claude-md', tmpDir, { HOME: tmpDir, GSD_RUNTIME: 'codex' });
assert.ok(result.success, `Command failed: ${result.error}`);
const parsed = JSON.parse(result.output);
const realTmpDir = fs.realpathSync(tmpDir);
const expectedAgentsPath = path.join(realTmpDir, 'AGENTS.md');
assert.strictEqual(parsed.claude_md_path, expectedAgentsPath,
`Expected output path to be AGENTS.md but got: ${parsed.claude_md_path}`
);
assert.ok(fs.existsSync(expectedAgentsPath), 'AGENTS.md must exist after generation');
assert.ok(!fs.existsSync(path.join(realTmpDir, 'CLAUDE.md')), 'CLAUDE.md must not be created when GSD_RUNTIME=codex');
});
test('--output flag overrides runtime detection when explicitly provided', () => {
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(
configPath,
JSON.stringify({ runtime: 'codex', claude_md_path: './CLAUDE.md' }),
'utf-8'
);
// When --output is explicitly provided, it must be honoured regardless of runtime
const result = runGsdTools(
['generate-claude-md', '--output', 'EXPLICIT-OUTPUT.md'],
tmpDir,
{ HOME: tmpDir }
);
assert.ok(result.success, `Command failed: ${result.error}`);
const parsed = JSON.parse(result.output);
const realTmpDir = fs.realpathSync(tmpDir);
assert.strictEqual(
parsed.claude_md_path,
path.join(realTmpDir, 'EXPLICIT-OUTPUT.md'),
`Expected explicit --output to be honoured, got: ${parsed.claude_md_path}`
);
});
test('non-codex runtime still writes to CLAUDE.md', () => {
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(
configPath,
JSON.stringify({ runtime: 'claude', claude_md_path: './CLAUDE.md' }),
'utf-8'
);
const result = runGsdTools('generate-claude-md', tmpDir, { HOME: tmpDir });
assert.ok(result.success, `Command failed: ${result.error}`);
const parsed = JSON.parse(result.output);
const realTmpDir = fs.realpathSync(tmpDir);
assert.strictEqual(
parsed.claude_md_path,
path.join(realTmpDir, 'CLAUDE.md'),
`Expected CLAUDE.md for claude runtime, got: ${parsed.claude_md_path}`
);
assert.ok(fs.existsSync(path.join(realTmpDir, 'CLAUDE.md')), 'CLAUDE.md must exist for claude runtime');
assert.ok(!fs.existsSync(path.join(realTmpDir, 'AGENTS.md')), 'AGENTS.md must not be created for claude runtime');
});
});

View File

@@ -1,60 +0,0 @@
'use strict';
/**
* Regression test for #3197 — gsd-tools config-set rejects workflow._auto_chain_active.
*
* Root cause: RUNTIME_STATE_KEYS was added to sdk/src/query/config-schema.ts in #3162
* but not to gsd-core/bin/lib/config-schema.cjs, so gsd-tools.cjs users still hit
* "Unknown config key" when setting workflow._auto_chain_active.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
describe('#3197 — gsd-tools.cjs config-set workflow._auto_chain_active', () => {
test('config-set workflow._auto_chain_active true succeeds via gsd-tools.cjs (CJS path)', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(['config-set', 'workflow._auto_chain_active', 'true'], tmpDir);
assert.ok(
result.success,
`config-set workflow._auto_chain_active true should succeed, got:\nstdout: ${result.output}\nstderr: ${result.error}`
);
});
test('config-set workflow._auto_chain_active true writes value to config.json', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
runGsdTools(['config-set', 'workflow._auto_chain_active', 'true'], tmpDir);
const configPath = path.join(tmpDir, '.planning', 'config.json');
assert.ok(fs.existsSync(configPath), '.planning/config.json must exist after config-set');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
assert.ok(
config.workflow !== undefined && config.workflow._auto_chain_active === true,
`Expected workflow._auto_chain_active: true in config.json, got: ${JSON.stringify(config)}`
);
});
test('config-set workflow._auto_chain_active false writes false to config.json', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
runGsdTools(['config-set', 'workflow._auto_chain_active', 'false'], tmpDir);
const configPath = path.join(tmpDir, '.planning', 'config.json');
assert.ok(fs.existsSync(configPath), '.planning/config.json must exist after config-set');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
assert.ok(
config.workflow !== undefined && config.workflow._auto_chain_active === false,
`Expected workflow._auto_chain_active: false in config.json, got: ${JSON.stringify(config)}`
);
});
});

View File

@@ -1,183 +0,0 @@
/**
* Regression tests for bug #3243.
*
* The CJS dispatcher (gsd-tools.cjs) must accept dotted canonical command
* form (e.g. `state.update`) as well as the spaced form (`state update`).
* Workflow markdown files emit `gsd-sdk query <domain>.<subcommand>` calls,
* and any caller that bypasses the SDK (stale npm binary, direct shell-out,
* third-party script) would hit "Unknown command: <domain>.<subcommand>".
*
* The fix: a top-of-main() shim that splits args[0] on the first `.` when
* present and normalizes to the spaced form before the switch is reached.
*
* This test file uses runGsdTools() — never readFileSync + .includes().
*/
'use strict';
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
describe('bug #3243: CJS dispatcher accepts dotted canonical command form', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
// ── generate-slug: no project structure needed, deterministic output ────
test('generate-slug.hello-world (dotted) produces the same slug as spaced form', () => {
const spaced = runGsdTools(['generate-slug', 'hello-world'], tmpDir);
assert.strictEqual(spaced.success, true, [
'control (spaced form) failed:',
spaced.error,
].join(' '));
const dotted = runGsdTools(['generate-slug.hello-world'], tmpDir);
// Before the fix this errors: "Unknown command: generate-slug.hello-world"
assert.strictEqual(dotted.success, true, [
'dotted form must not emit "Unknown command":',
dotted.error,
].join(' '));
assert.strictEqual(dotted.output, spaced.output,
'dotted form must produce identical output to spaced form');
});
test('current-timestamp.date (dotted) produces the same output as spaced form', () => {
const spaced = runGsdTools(['current-timestamp', 'date'], tmpDir);
assert.strictEqual(spaced.success, true, [
'control (spaced form) failed:',
spaced.error,
].join(' '));
const dotted = runGsdTools(['current-timestamp.date'], tmpDir);
assert.strictEqual(dotted.success, true, [
'dotted form must not emit "Unknown command":',
dotted.error,
].join(' '));
assert.strictEqual(dotted.output, spaced.output,
'dotted form must produce identical output to spaced form');
});
// ── Commands with subcommands that need a project ────────────────────────
test('validate.plan (dotted) routes into validate handler, not "Unknown command"', () => {
const dotted = runGsdTools(['validate.plan'], tmpDir);
// Before the fix: success=false, error contains "Unknown command: validate.plan"
// After the fix: success=false is still possible (validate needs a PLAN.md),
// but the error must NOT mention "Unknown command".
const errText = dotted.error || '';
assert.ok(
!errText.includes('Unknown command: validate.plan'),
[
'dotted form must not produce "Unknown command: validate.plan".',
'Got error:', errText,
].join('\n')
);
});
test('roadmap.analyze (dotted) routes into roadmap handler, not "Unknown command"', () => {
const dotted = runGsdTools(['roadmap.analyze'], tmpDir);
// success=true means it reached the handler (even if handler reports no ROADMAP.md).
// success=false means dispatcher rejected it — assert the error is NOT "Unknown command".
const errText = dotted.error || '';
assert.ok(
!errText.includes('Unknown command: roadmap.analyze'),
[
'dotted form must not produce "Unknown command: roadmap.analyze".',
'Got error:', errText,
].join('\n')
);
});
test('phases.list (dotted) routes into phases handler, not "Unknown command"', () => {
const dotted = runGsdTools(['phases.list'], tmpDir);
const errText = dotted.error || '';
assert.ok(
!errText.includes('Unknown command: phases.list'),
[
'dotted form must not produce "Unknown command: phases.list".',
'Got error:', errText,
].join('\n')
);
});
// ── Multi-dot commands: split on first dot only ──────────────────────────
test('check.decision-coverage-plan (multi-dot-safe: first dot splits)', () => {
const dotted = runGsdTools(['check.decision-coverage-plan'], tmpDir);
// "check" is not a known top-level command currently, so this will still
// fail — but the error must NOT say "Unknown command: check.decision-coverage-plan"
// (the dotted form); it should say something about "check" (the split result).
const errText = dotted.error || '';
assert.ok(
!errText.includes('Unknown command: check.decision-coverage-plan'),
[
'multi-dot dotted form must not be passed verbatim to "Unknown command".',
'Got error:', errText,
].join('\n')
);
});
// ── Edge cases ────────────────────────────────────────────────────────────
test('command without dots is unchanged (existing behaviour preserved)', () => {
const result = runGsdTools(['generate-slug', 'no-dots-here'], tmpDir);
assert.strictEqual(result.success, true, [
'spaced-only invocation must still work:',
result.error,
].join(' '));
assert.ok(result.output.length > 0, 'output must be non-empty');
});
test('leading-dot arg (e.g. .hidden) is not mis-routed by the shim', () => {
// A leading dot in args[0] like ".hidden" has head="" (empty) after split,
// so the shim must reject it and fall through to the existing "Unknown command"
// path (not silently reroute to an empty-string command).
const result = runGsdTools(['.hidden'], tmpDir);
assert.strictEqual(result.success, false, 'leading-dot arg must not succeed');
});
// ── "Unknown command" error message improvement ──────────────────────────
test('"Unknown command" error for dotted form suggests spaced equivalent', () => {
// A genuinely unknown dotted command (e.g. "foo.bar") should include a
// "did you mean" hint pointing at the spaced form "foo bar".
const result = runGsdTools(['foo.bar'], tmpDir);
assert.strictEqual(result.success, false, '"foo.bar" must fail');
assert.ok(
result.error.includes('foo bar'),
[
'error for unknown dotted command should suggest spaced form "foo bar".',
'Got:', result.error,
].join('\n')
);
});
test('multi-dot unknown command suggestion uses first-dot split only (a.b.c → "a b.c")', () => {
// The shim splits only on the FIRST dot, so the suggestion must mirror that:
// "a.b.c" → head="a", rest="b.c" → suggest "a b.c", NOT "a b c".
const result = runGsdTools(['a.b.c'], tmpDir);
assert.strictEqual(result.success, false, '"a.b.c" must fail');
assert.ok(
result.error.includes('a b.c'),
[
'suggestion for multi-dot unknown command must use first-dot split: "a b.c".',
'Got:', result.error,
].join('\n')
);
assert.ok(
!result.error.includes('a b c'),
[
'suggestion must NOT replace all dots ("a b c" is wrong — only first dot splits).',
'Got:', result.error,
].join('\n')
);
});
});

View File

@@ -1,568 +0,0 @@
/**
* GSD Tools Tests — Bug #3257
*
* Regression guard: `buildStateFrontmatter` must count plan/summary files in
* the nested `phases/<N>-<slug>/plans/<N>-PLAN-<NN>-<slug>.md` layout (written
* by gsd-plan-phase post-#3139). Prior to this fix, the loop did a flat
* `readdirSync` on the phase directory and missed every file inside the
* `plans/` subdirectory, so `progress.total_plans` and
* `progress.completed_plans` were silently under-counted on every state
* mutation that flows through `syncStateFrontmatter → buildStateFrontmatter`.
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
/**
* Write a minimal STATE.md that will trigger syncStateFrontmatter on any write.
*/
function writeStateFile(tmpDir, overrides = {}) {
const phase = overrides.phase || '01';
const status = overrides.status || 'executing';
const content = [
'# Project State',
'',
`**Current Phase:** ${phase}`,
`**Status:** ${status}`,
'',
'## Current Position',
'',
`Phase: ${phase} — In progress`,
'Status: Executing',
'',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content, 'utf-8');
}
/**
* Write a ROADMAP.md listing the given phase numbers so the milestone-scoped
* filter includes them (avoids needing a milestone header to count phases).
*/
function writeRoadmap(tmpDir, phaseNums) {
const lines = ['## Roadmap v1.0'];
for (const n of phaseNums) {
lines.push('', `### Phase ${n}: Phase ${n}`);
}
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
lines.join('\n'),
'utf-8'
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Nested layout — core bug (#3257)
// ─────────────────────────────────────────────────────────────────────────────
describe('buildStateFrontmatter nested plans/ layout (#3257)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('counts plans and summaries in nested plans/ subdirectory', () => {
// Layout: phases/01-init/plans/1-PLAN-01-setup.md etc.
// 2 phases × 3 plans each, all completed (3 summaries each).
for (let phase = 1; phase <= 2; phase++) {
const phaseSlug = `0${phase}-phase-${phase}`;
const phaseDir = path.join(tmpDir, '.planning', 'phases', phaseSlug);
const plansDir = path.join(phaseDir, 'plans');
fs.mkdirSync(plansDir, { recursive: true });
for (let plan = 1; plan <= 3; plan++) {
const planPad = String(plan).padStart(2, '0');
// Reporter's format: {N}-PLAN-{NN}-{slug}.md
const planFile = `${phase}-PLAN-${planPad}-step${plan}.md`;
const summaryFile = `${phase}-SUMMARY-${planPad}-step${plan}.md`;
fs.writeFileSync(path.join(plansDir, planFile), '# Plan\n');
fs.writeFileSync(path.join(plansDir, summaryFile), '# Summary\n');
}
}
writeRoadmap(tmpDir, [1, 2]);
writeStateFile(tmpDir, { phase: '02' });
const result = runGsdTools('state update "Last Activity" "2026-05-08"', tmpDir);
assert.ok(result.success, `state update failed: ${result.error}`);
const jsonResult = runGsdTools('state json', tmpDir);
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
const progress = JSON.parse(jsonResult.output).progress;
assert.strictEqual(Number(progress.total_plans), 6, 'total_plans must count nested plans/ files (2 phases × 3 plans)');
assert.strictEqual(Number(progress.completed_plans), 6, 'completed_plans must count nested summary files (2 phases × 3 summaries)');
assert.strictEqual(Number(progress.completed_phases), 2, 'completed_phases: both phases have summaries >= plans');
});
test('counts PLAN-NN-slug form (bare PLAN- prefix, no phase prefix)', () => {
// roadmap.cjs uses /^PLAN-\d+.*\.md$/i — test that form too.
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-init');
const plansDir = path.join(phaseDir, 'plans');
fs.mkdirSync(plansDir, { recursive: true });
fs.writeFileSync(path.join(plansDir, 'PLAN-01-foundation.md'), '# Plan\n');
fs.writeFileSync(path.join(plansDir, 'PLAN-02-infra.md'), '# Plan\n');
fs.writeFileSync(path.join(plansDir, 'SUMMARY-01-foundation.md'), '# Summary\n');
writeRoadmap(tmpDir, [1]);
writeStateFile(tmpDir, { phase: '01' });
const result = runGsdTools('state update "Last Activity" "2026-05-08"', tmpDir);
assert.ok(result.success, `state update failed: ${result.error}`);
const jsonResult = runGsdTools('state json', tmpDir);
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
const progress = JSON.parse(jsonResult.output).progress;
assert.strictEqual(Number(progress.total_plans), 2, 'bare PLAN-NN-slug.md files must be counted');
assert.strictEqual(Number(progress.completed_plans), 1, 'SUMMARY-NN-slug.md files must be counted');
// 1 summary < 2 plans → phase NOT completed
assert.strictEqual(Number(progress.completed_phases), 0, 'phase not complete when summaries < plans');
});
test('flat-layout repos are unaffected (no plans/ subdirectory)', () => {
// Pre-#3139 flat layout: plans live directly in the phase dir.
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-init');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
fs.writeFileSync(path.join(phaseDir, '01-02-PLAN.md'), '# Plan\n');
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary\n');
fs.writeFileSync(path.join(phaseDir, '01-02-SUMMARY.md'), '# Summary\n');
writeRoadmap(tmpDir, [1]);
writeStateFile(tmpDir, { phase: '01' });
const result = runGsdTools('state update "Last Activity" "2026-05-08"', tmpDir);
assert.ok(result.success, `state update failed: ${result.error}`);
const jsonResult = runGsdTools('state json', tmpDir);
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
const progress = JSON.parse(jsonResult.output).progress;
assert.strictEqual(Number(progress.total_plans), 2, 'flat layout: top-level *-PLAN.md files counted');
assert.strictEqual(Number(progress.completed_plans), 2, 'flat layout: top-level *-SUMMARY.md files counted');
assert.strictEqual(Number(progress.completed_phases), 1, 'flat layout: phase complete when summaries >= plans');
});
test('no double-count when both top-level and nested plan files coexist', () => {
// Edge case: phase has a top-level plan AND a plans/ subdir.
// Only the nested files should be counted (or both, depending on logic),
// but the critical thing is no file is counted twice.
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-init');
const plansDir = path.join(phaseDir, 'plans');
fs.mkdirSync(plansDir, { recursive: true });
// Top-level flat plan
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Top-level Plan\n');
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Top-level Summary\n');
// Nested plan
fs.writeFileSync(path.join(plansDir, '1-PLAN-02-nested.md'), '# Nested Plan\n');
fs.writeFileSync(path.join(plansDir, '1-SUMMARY-02-nested.md'), '# Nested Summary\n');
writeRoadmap(tmpDir, [1]);
writeStateFile(tmpDir, { phase: '01' });
const result = runGsdTools('state update "Last Activity" "2026-05-08"', tmpDir);
assert.ok(result.success, `state update failed: ${result.error}`);
const jsonResult = runGsdTools('state json', tmpDir);
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
const progress = JSON.parse(jsonResult.output).progress;
// 1 top-level + 1 nested = 2 total (not 4 from double-counting)
assert.strictEqual(Number(progress.total_plans), 2, 'mixed layout: no double-counting of plan files');
assert.strictEqual(Number(progress.completed_plans), 2, 'mixed layout: no double-counting of summary files');
});
test('empty plans/ directory is a no-op (does not break counting)', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-init');
const plansDir = path.join(phaseDir, 'plans');
fs.mkdirSync(plansDir, { recursive: true });
// plans/ dir exists but is empty
// One top-level plan
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary\n');
writeRoadmap(tmpDir, [1]);
writeStateFile(tmpDir, { phase: '01' });
const result = runGsdTools('state update "Last Activity" "2026-05-08"', tmpDir);
assert.ok(result.success, `state update failed: ${result.error}`);
const jsonResult = runGsdTools('state json', tmpDir);
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
const progress = JSON.parse(jsonResult.output).progress;
assert.strictEqual(Number(progress.total_plans), 1, 'empty plans/ must not add phantom plan count');
assert.strictEqual(Number(progress.completed_plans), 1, 'empty plans/ must not affect summary count');
assert.strictEqual(Number(progress.completed_phases), 1, 'phase complete: 1 summary >= 1 plan');
});
test('PLAN-OUTLINE.md files are excluded from nested plan count', () => {
// phase.cjs explicitly excludes *-PLAN-OUTLINE.md (not real plans).
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-init');
const plansDir = path.join(phaseDir, 'plans');
fs.mkdirSync(plansDir, { recursive: true });
fs.writeFileSync(path.join(plansDir, '1-PLAN-01-work.md'), '# Real Plan\n');
// Outline file — should NOT count as a plan
fs.writeFileSync(path.join(plansDir, '1-PLAN-OUTLINE.md'), '# Outline\n');
writeRoadmap(tmpDir, [1]);
writeStateFile(tmpDir, { phase: '01' });
const result = runGsdTools('state update "Last Activity" "2026-05-08"', tmpDir);
assert.ok(result.success, `state update failed: ${result.error}`);
const jsonResult = runGsdTools('state json', tmpDir);
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
const progress = JSON.parse(jsonResult.output).progress;
// Only the real plan should count; outline excluded.
assert.strictEqual(Number(progress.total_plans), 1, 'PLAN-OUTLINE.md must not count as a plan');
});
test('pre-bounce files are excluded from nested plan count (bare PLAN- prefix)', () => {
// CR finding: PLAN_PRE_BOUNCE_RE was /-PLAN.*\.pre-bounce\.md$/i which missed
// bare-prefix files like PLAN-01-foo.pre-bounce.md. Fixed to /\.pre-bounce\.md$/i.
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-init');
const plansDir = path.join(phaseDir, 'plans');
fs.mkdirSync(plansDir, { recursive: true });
fs.writeFileSync(path.join(plansDir, '1-PLAN-01-work.md'), '# Real Plan\n');
// Pre-bounce files — should NOT count as plans
fs.writeFileSync(path.join(plansDir, 'PLAN-01-work.pre-bounce.md'), '# Pre-bounce\n');
fs.writeFileSync(path.join(plansDir, '1-PLAN-01-work.pre-bounce.md'), '# Pre-bounce\n');
writeRoadmap(tmpDir, [1]);
writeStateFile(tmpDir, { phase: '01' });
const result = runGsdTools('state update "Last Activity" "2026-05-08"', tmpDir);
assert.ok(result.success, `state update failed: ${result.error}`);
const jsonResult = runGsdTools('state json', tmpDir);
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
const progress = JSON.parse(jsonResult.output).progress;
// Only the real plan should count; pre-bounce files excluded.
assert.strictEqual(Number(progress.total_plans), 1, 'pre-bounce files must not count as plans');
});
test('reporter scenario: 2 phases × multiple plans, all complete', () => {
// Mirrors the reporter's observation: after a state mutation the progress
// block should reflect the TRUE on-disk count, not an under-count.
// Phase 1: 4 plans, all with summaries.
// Phase 2: 3 plans, all with summaries.
// Expected: total=7, completed=7, completed_phases=2.
const phases = [
{ num: 1, plans: 4 },
{ num: 2, plans: 3 },
];
for (const { num, plans } of phases) {
const phaseDir = path.join(tmpDir, '.planning', 'phases', `0${num}-phase-${num}`);
const plansDir = path.join(phaseDir, 'plans');
fs.mkdirSync(plansDir, { recursive: true });
for (let p = 1; p <= plans; p++) {
const pad = String(p).padStart(2, '0');
fs.writeFileSync(path.join(plansDir, `${num}-PLAN-${pad}-task${p}.md`), '# Plan\n');
fs.writeFileSync(path.join(plansDir, `${num}-SUMMARY-${pad}-task${p}.md`), '# Summary\n');
}
}
writeRoadmap(tmpDir, [1, 2]);
writeStateFile(tmpDir, { phase: '02' });
const result = runGsdTools('state update "Last Activity" "2026-05-08"', tmpDir);
assert.ok(result.success, `state update failed: ${result.error}`);
const jsonResult = runGsdTools('state json', tmpDir);
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
const progress = JSON.parse(jsonResult.output).progress;
assert.strictEqual(Number(progress.total_plans), 7, 'reporter scenario: total_plans must be 7');
assert.strictEqual(Number(progress.completed_plans), 7, 'reporter scenario: completed_plans must be 7');
assert.strictEqual(Number(progress.completed_phases), 2, 'reporter scenario: both phases complete');
assert.strictEqual(Number(progress.percent), 100, 'reporter scenario: 100% when all plans have summaries');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// cmdStateValidate nested plans/ layout (#3257 — CR finding)
//
// Prior to this fix, cmdStateValidate did a flat readdirSync on the phase dir
// and returned diskPlans=0 for nested layouts, causing false drift warnings
// when STATE.md correctly said "Total Plans in Phase: 3".
// ─────────────────────────────────────────────────────────────────────────────
describe('cmdStateValidate nested plans/ layout (#3257)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('no false drift warning when STATE.md plan count matches nested disk count', () => {
// Phase 01-init: 3 nested plans, 0 summaries (still executing).
// STATE.md says "Total Plans in Phase: 3" — after the fix, validate sees
// diskPlans=3 and emits no plan_count drift warning.
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-init');
const plansDir = path.join(phaseDir, 'plans');
fs.mkdirSync(plansDir, { recursive: true });
for (let p = 1; p <= 3; p++) {
const pad = String(p).padStart(2, '0');
fs.writeFileSync(path.join(plansDir, `1-PLAN-${pad}-step${p}.md`), '# Plan\n');
}
// Write STATE.md with correct plan count so validate can check for drift.
const stateContent = [
'# Project State',
'',
'**Current Phase:** 01',
'**Status:** executing',
'**Total Plans in Phase:** 3',
'',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateContent, 'utf-8');
const result = runGsdTools('state validate', tmpDir);
assert.ok(result.success, `state validate failed: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.ok(parsed.valid, `state validate should be valid; warnings: ${JSON.stringify(parsed.warnings)}`);
assert.deepStrictEqual(parsed.warnings, [], 'no drift warnings for nested-layout phase with correct plan count');
assert.ok(!parsed.drift.plan_count, 'no plan_count drift when nested scan matches STATE.md');
});
test('emits drift warning when STATE.md plan count does not match nested disk count', () => {
// STATE.md says 5 but only 2 plans exist on disk — validate should catch it.
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-init');
const plansDir = path.join(phaseDir, 'plans');
fs.mkdirSync(plansDir, { recursive: true });
for (let p = 1; p <= 2; p++) {
const pad = String(p).padStart(2, '0');
fs.writeFileSync(path.join(plansDir, `1-PLAN-${pad}-step${p}.md`), '# Plan\n');
}
const stateContent = [
'# Project State',
'',
'**Current Phase:** 01',
'**Status:** executing',
'**Total Plans in Phase:** 5',
'',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateContent, 'utf-8');
const result = runGsdTools('state validate', tmpDir);
assert.ok(result.success, `state validate failed: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.ok(!parsed.valid, 'state validate should report invalid when plan counts differ');
assert.ok(parsed.warnings.length > 0, 'at least one drift warning expected');
assert.ok(parsed.drift.plan_count, 'plan_count drift object must be present');
assert.strictEqual(parsed.drift.plan_count.disk, 2, 'disk count must reflect nested scan (2 nested plans)');
assert.strictEqual(parsed.drift.plan_count.state, 5, 'state count from STATE.md must be 5');
});
test('PLAN-OUTLINE.md excluded from nested count in validate', () => {
// Outline files must not inflate diskPlans and cause false "too few" drift.
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-init');
const plansDir = path.join(phaseDir, 'plans');
fs.mkdirSync(plansDir, { recursive: true });
fs.writeFileSync(path.join(plansDir, '1-PLAN-01-work.md'), '# Plan\n');
fs.writeFileSync(path.join(plansDir, '1-PLAN-OUTLINE.md'), '# Outline\n'); // must not count
// STATE.md claims 1 plan — correct after exclusion.
const stateContent = [
'# Project State',
'',
'**Current Phase:** 01',
'**Status:** executing',
'**Total Plans in Phase:** 1',
'',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateContent, 'utf-8');
const result = runGsdTools('state validate', tmpDir);
assert.ok(result.success, `state validate failed: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.ok(parsed.valid, `should be valid (outline excluded); warnings: ${JSON.stringify(parsed.warnings)}`);
assert.ok(!parsed.drift.plan_count, 'no plan_count drift when outline excluded from nested count');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// cmdStateSync nested plans/ layout (#3257 — CR finding)
//
// Prior to this fix, cmdStateSync did a flat readdirSync on each phase dir,
// returning plans=0 for nested layouts. It would set "Total Plans in Phase"
// to 0 even when plans existed inside plans/ — an under-count that corrupts
// the STATE.md progress block.
// ─────────────────────────────────────────────────────────────────────────────
describe('cmdStateSync nested plans/ layout (#3257)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('updates Total Plans in Phase from 0 to correct nested count on sync', () => {
// Disk: phase 01-init with 3 nested plans, no summaries.
// STATE.md says "Total Plans in Phase: 0" (stale / pre-fix value).
// After sync, the field must be updated to 3.
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-init');
const plansDir = path.join(phaseDir, 'plans');
fs.mkdirSync(plansDir, { recursive: true });
for (let p = 1; p <= 3; p++) {
const pad = String(p).padStart(2, '0');
fs.writeFileSync(path.join(plansDir, `1-PLAN-${pad}-step${p}.md`), '# Plan\n');
}
const stateContent = [
'# Project State',
'',
'**Current Phase:** 01',
'**Status:** executing',
'**Total Plans in Phase:** 0',
'**Progress:** [░░░░░░░░░░] 0%',
'**Last Activity:** 2026-01-01',
'',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateContent, 'utf-8');
const result = runGsdTools('state sync', tmpDir);
assert.ok(result.success, `state sync failed: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.ok(parsed.synced, 'sync must report synced: true');
// The "Total Plans in Phase" change must appear in the changes list.
const planCountChange = parsed.changes.find(c => c.startsWith('Total Plans in Phase:'));
assert.ok(planCountChange, `changes must include Total Plans in Phase update; got: ${JSON.stringify(parsed.changes)}`);
assert.ok(planCountChange.includes('-> 3'), `Total Plans in Phase must update to 3; got: "${planCountChange}"`);
});
test('sync dry-run reports correct nested plan count without writing', () => {
// --verify flag: sync must report what WOULD change but not write STATE.md.
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-init');
const plansDir = path.join(phaseDir, 'plans');
fs.mkdirSync(plansDir, { recursive: true });
for (let p = 1; p <= 2; p++) {
const pad = String(p).padStart(2, '0');
fs.writeFileSync(path.join(plansDir, `PLAN-${pad}-task${p}.md`), '# Plan\n');
}
const stateContent = [
'# Project State',
'',
'**Current Phase:** 01',
'**Status:** executing',
'**Total Plans in Phase:** 0',
'**Progress:** [░░░░░░░░░░] 0%',
'**Last Activity:** 2026-01-01',
'',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateContent, 'utf-8');
const result = runGsdTools('state sync --verify', tmpDir);
assert.ok(result.success, `state sync --verify failed: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.ok(parsed.dry_run, 'dry_run must be true with --verify flag');
const planCountChange = parsed.changes.find(c => c.startsWith('Total Plans in Phase:'));
assert.ok(planCountChange, `dry-run changes must include Total Plans in Phase; got: ${JSON.stringify(parsed.changes)}`);
assert.ok(planCountChange.includes('-> 2'), `dry-run must show correct count of 2; got: "${planCountChange}"`);
// STATE.md must be unchanged (dry-run): re-run sync --verify and confirm the
// same pending change is still reported (if STATE.md had been written, the
// change would have been applied and the second run would show no changes).
const result2 = runGsdTools('state sync --verify', tmpDir);
assert.ok(result2.success, `second dry-run failed: ${result2.error}`);
const parsed2 = JSON.parse(result2.output);
const planCountChange2 = parsed2.changes.find(c => c.startsWith('Total Plans in Phase:'));
assert.ok(planCountChange2, 'repeated dry-run must still report pending change (file was not mutated on disk)');
});
test('sync across multiple phases with nested plans sums correctly', () => {
// Phase 01: 2 nested plans, 2 summaries (complete).
// Phase 02: 3 nested plans, 1 summary (in progress).
// Expected "Total Plans in Phase" = 3 (current/incomplete phase).
const phases = [
{ dir: '01-alpha', plans: 2, summaries: 2 },
{ dir: '02-beta', plans: 3, summaries: 1 },
];
for (const { dir, plans, summaries } of phases) {
const phaseDir = path.join(tmpDir, '.planning', 'phases', dir);
const plansDir = path.join(phaseDir, 'plans');
fs.mkdirSync(plansDir, { recursive: true });
for (let p = 1; p <= plans; p++) {
const pad = String(p).padStart(2, '0');
fs.writeFileSync(path.join(plansDir, `1-PLAN-${pad}-t.md`), '# Plan\n');
}
for (let s = 1; s <= summaries; s++) {
const pad = String(s).padStart(2, '0');
fs.writeFileSync(path.join(plansDir, `1-SUMMARY-${pad}-t.md`), '# Summary\n');
}
}
const stateContent = [
'# Project State',
'',
'**Current Phase:** 02',
'**Status:** executing',
'**Total Plans in Phase:** 0',
'**Progress:** [░░░░░░░░░░] 0%',
'**Last Activity:** 2026-01-01',
'',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateContent, 'utf-8');
const result = runGsdTools('state sync', tmpDir);
assert.ok(result.success, `state sync failed: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.ok(parsed.synced, 'sync must succeed');
// "Total Plans in Phase" reflects the current (incomplete) phase: 02-beta has 3 plans.
const planCountChange = parsed.changes.find(c => c.startsWith('Total Plans in Phase:'));
assert.ok(planCountChange, `Total Plans in Phase change expected; got: ${JSON.stringify(parsed.changes)}`);
assert.ok(planCountChange.includes('-> 3'), `current phase plan count must be 3; got: "${planCountChange}"`);
// Progress: computeProgressPercent uses min(plan_fraction, phase_fraction).
// plan_fraction = 3 summaries / 5 plans = 60%.
// phase_fraction = 1 completed phase / 2 total phases = 50%.
// min(60%, 50%) = 50% — the phase cap applies (#3242).
const progressChange = parsed.changes.find(c => c.startsWith('Progress:'));
assert.ok(progressChange, `Progress change expected; got: ${JSON.stringify(parsed.changes)}`);
assert.ok(progressChange.includes('50%'), `progress must reflect nested counts (min(3/5, 1/2)=50%); got: "${progressChange}"`);
});
});

View File

@@ -1,146 +0,0 @@
/**
* GSD Tools Tests — Bug #3275 (CR finding)
*
* Regression guard: `state-snapshot` must prefer YAML frontmatter scalar
* values even when those scalars are numeric (e.g. current_phase: 19) or
* boolean — not just when they are strings.
*
* Prior to the fix, `fmStr` checked `typeof v === 'string'`, so a numeric
* frontmatter value like `current_phase: 19` was treated as missing and the
* snapshot fell back to body extraction, which could return a stale or
* incorrect value.
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
describe('state-snapshot: fmStr accepts non-string YAML scalars (#3275 CR)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('numeric current_phase in frontmatter wins over body extraction', () => {
// YAML parses bare integers as numbers, not strings.
// fmStr must not drop the frontmatter value when it is a number.
const stateMd = [
'---',
'gsd_state_version: 1.0',
'current_phase: 19',
'---',
'',
'# Project State',
'',
'**Current Phase:** 03',
'**Status:** executing',
'',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateMd);
const result = runGsdTools('state-snapshot', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
// Frontmatter numeric value must win over bold-body value
assert.strictEqual(output.current_phase, '19', 'numeric frontmatter current_phase must be used');
});
test('numeric total_phases in frontmatter wins over body extraction', () => {
const stateMd = [
'---',
'gsd_state_version: 1.0',
'total_phases: 7',
'---',
'',
'# Project State',
'',
'**Total Phases:** 3',
'**Status:** executing',
'',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateMd);
const result = runGsdTools('state-snapshot', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
// Frontmatter says 7, body says 3 — frontmatter must win
assert.strictEqual(output.total_phases, 7, 'numeric frontmatter total_phases must be used');
});
test('numeric total_plans_in_phase in frontmatter wins over body extraction', () => {
const stateMd = [
'---',
'gsd_state_version: 1.0',
'total_plans_in_phase: 5',
'---',
'',
'# Project State',
'',
'**Total Plans in Phase:** 2',
'**Status:** executing',
'',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateMd);
const result = runGsdTools('state-snapshot', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.total_plans_in_phase, 5, 'numeric frontmatter total_plans_in_phase must be used');
});
test('string current_phase in frontmatter still works (no regression)', () => {
const stateMd = [
'---',
'gsd_state_version: 1.0',
"current_phase: '19'",
'---',
'',
'# Project State',
'',
'**Current Phase:** 03',
'**Status:** executing',
'',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateMd);
const result = runGsdTools('state-snapshot', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.current_phase, '19', 'string frontmatter current_phase still works');
});
test('no-frontmatter file still extracts from body (no regression)', () => {
const stateMd = [
'# Project State',
'',
'**Current Phase:** 05',
'**Total Phases:** 8',
'**Status:** paused',
'',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateMd);
const result = runGsdTools('state-snapshot', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.current_phase, '05', 'body extraction still works without frontmatter');
assert.strictEqual(output.total_phases, 8, 'numeric body total_phases still extracted');
});
});

View File

@@ -1,396 +0,0 @@
'use strict';
// Regression tests for issue #3286 — three bugs in state.cjs:
//
// Bug A: cmdStateRecordMetric / cmdStateAddDecision return { recorded: false }
// with exit code 0 when their target section is absent. gsd-executor treats
// exit 0 as success, silently losing metrics/decisions across an entire phase.
// Fix: auto-create the missing section (Bug B subsumes A — silent no-op
// disappears). When auto-created, JSON must include created: true.
//
// Bug B: A fresh STATE.md without ## Performance Metrics or ## Decisions causes
// both verbs to silently no-op. DWIM: auto-create the canonical section scaffold
// and then write the row/entry, matching state begin-phase / advance-plan behavior.
//
// Bug C: state record-metric and add-decision must honor --ws <name>, routing
// writes to .planning/workstreams/<name>/STATE.md instead of root STATE.md.
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
// ─────────────────────────────────────────────────────────────────────────────
// Fixtures
// ─────────────────────────────────────────────────────────────────────────────
/** Build a minimal STATE.md with all canonical sections */
function buildFullStateMd() {
return [
'# GSD State',
'',
'## Configuration',
'Current Phase: 1',
'Current Phase Name: bootstrap',
'Total Plans in Phase: 3',
'Current Plan: 1',
'Status: Executing',
'Last Activity: 2026-01-01',
'',
'## Performance Metrics',
'',
'| Phase | Plan | Duration | Notes |',
'|-------|------|----------|-------|',
'',
'## Decisions',
'',
'None yet.',
'',
'### Blockers',
'',
'None.',
'',
].join('\n');
}
/** Build a STATE.md WITHOUT Performance Metrics or Decisions sections */
function buildBareboneStateMd() {
return [
'# GSD State',
'',
'## Configuration',
'Current Phase: 1',
'Current Phase Name: bootstrap',
'Total Plans in Phase: 3',
'Current Plan: 1',
'Status: Executing',
'Last Activity: 2026-01-01',
'',
].join('\n');
}
// ─────────────────────────────────────────────────────────────────────────────
// Group B: auto-create missing sections (DWIM)
// ─────────────────────────────────────────────────────────────────────────────
describe('#3286 Bug B: record-metric auto-creates ## Performance Metrics when missing', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('record-metric succeeds when ## Performance Metrics is absent', () => {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, buildBareboneStateMd());
const result = runGsdTools(
['state', 'record-metric', '--phase', '1', '--plan', '1', '--duration', '45min'],
tmpDir,
);
assert.ok(result.success, `record-metric must succeed (exit 0), got: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.strictEqual(parsed.recorded, true, `recorded must be true, got: ${JSON.stringify(parsed)}`);
});
test('record-metric with created:true when section was auto-created', () => {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, buildBareboneStateMd());
const result = runGsdTools(
['state', 'record-metric', '--phase', '1', '--plan', '1', '--duration', '45min'],
tmpDir,
);
assert.ok(result.success, `record-metric must succeed, got: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.strictEqual(parsed.created, true, `JSON must include created:true when section was auto-created`);
});
test('record-metric appends row into auto-created section — verifiable via state snapshot', () => {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, buildBareboneStateMd());
const result = runGsdTools(
['state', 'record-metric', '--phase', '1', '--plan', '2', '--duration', '30min', '--tasks', '5'],
tmpDir,
);
assert.ok(result.success, `record-metric must succeed, got: ${result.error}`);
// Verify the metric appeared in the file by calling state get to read the section
const getResult = runGsdTools(['state', 'get', 'Performance Metrics'], tmpDir);
assert.ok(getResult.success, `state get must succeed, got: ${getResult.error}`);
// Parse JSON to check structural content (no .includes on raw file)
const sectionContent = JSON.parse(getResult.output);
const sectionText = sectionContent['Performance Metrics'] || '';
// Must contain a row referencing Phase 1 P2
assert.ok(
sectionText.includes('Phase 1 P2') || sectionText.includes('| Phase 1 P2'),
`Performance Metrics section must contain the appended row. Got section: ${sectionText}`,
);
});
test('record-metric on state with existing section still works (no regression)', () => {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, buildFullStateMd());
const result = runGsdTools(
['state', 'record-metric', '--phase', '2', '--plan', '1', '--duration', '1h'],
tmpDir,
);
assert.ok(result.success, `record-metric must succeed on existing section, got: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.strictEqual(parsed.recorded, true, `recorded must be true`);
// created should be absent or false when section already existed
assert.ok(!parsed.created, `created must be absent/false when section existed`);
});
});
describe('#3286 Bug B: add-decision auto-creates ## Decisions when missing', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('add-decision succeeds when ## Decisions is absent', () => {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, buildBareboneStateMd());
const result = runGsdTools(
['state', 'add-decision', '--phase', '1', '--summary', 'Use TypeScript for type safety'],
tmpDir,
);
assert.ok(result.success, `add-decision must succeed (exit 0), got: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.strictEqual(parsed.added, true, `added must be true, got: ${JSON.stringify(parsed)}`);
});
test('add-decision with created:true when section was auto-created', () => {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, buildBareboneStateMd());
const result = runGsdTools(
['state', 'add-decision', '--phase', '1', '--summary', 'Use Redis for caching'],
tmpDir,
);
assert.ok(result.success, `add-decision must succeed, got: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.strictEqual(parsed.created, true, `JSON must include created:true when Decisions section auto-created`);
});
test('add-decision appended entry is visible in state get', () => {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, buildBareboneStateMd());
const summary = 'Adopt PostgreSQL over MySQL for JSONB support';
const result = runGsdTools(
['state', 'add-decision', '--phase', '2', '--summary', summary],
tmpDir,
);
assert.ok(result.success, `add-decision must succeed, got: ${result.error}`);
// Verify via state get (structured), not raw file grep
const getResult = runGsdTools(['state', 'get', 'Decisions'], tmpDir);
assert.ok(getResult.success, `state get Decisions must succeed, got: ${getResult.error}`);
const sectionContent = JSON.parse(getResult.output);
const sectionText = sectionContent['Decisions'] || '';
assert.ok(
sectionText.includes(summary),
`Decisions section must contain the appended decision. Got: ${sectionText}`,
);
});
test('add-decision on state with existing Decisions section works (no regression)', () => {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, buildFullStateMd());
const result = runGsdTools(
['state', 'add-decision', '--phase', '1', '--summary', 'Use monorepo layout'],
tmpDir,
);
assert.ok(result.success, `add-decision must succeed on existing section, got: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.strictEqual(parsed.added, true, `added must be true`);
assert.ok(!parsed.created, `created must be absent/false when section already existed`);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Group A: exit code contract (covered by Bug B fix — no silent no-op)
// ─────────────────────────────────────────────────────────────────────────────
describe('#3286 Bug A: record-metric / add-decision never silently no-op', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('record-metric always has recorded:true (never silent false)', () => {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
// Minimal state — no Performance Metrics section
fs.writeFileSync(statePath, buildBareboneStateMd());
const result = runGsdTools(
['state', 'record-metric', '--phase', '1', '--plan', '1', '--duration', '20min'],
tmpDir,
);
// Must exit 0 AND recorded must be true (auto-created or found)
assert.ok(result.success, `record-metric must exit 0, got stderr: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.strictEqual(
parsed.recorded,
true,
`recorded must be true — section auto-create should prevent silent false. Got: ${JSON.stringify(parsed)}`,
);
});
test('add-decision always has added:true (never silent false)', () => {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, buildBareboneStateMd());
const result = runGsdTools(
['state', 'add-decision', '--phase', '1', '--summary', 'Prefer composition over inheritance'],
tmpDir,
);
assert.ok(result.success, `add-decision must exit 0, got stderr: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.strictEqual(
parsed.added,
true,
`added must be true — section auto-create should prevent silent false. Got: ${JSON.stringify(parsed)}`,
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Group C: workstream routing — writes go to workstream STATE.md, not root
// ─────────────────────────────────────────────────────────────────────────────
describe('#3286 Bug C: record-metric / add-decision honor --ws routing', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
// Create root STATE.md with Performance Metrics + Decisions sections
const rootStatePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(rootStatePath, buildFullStateMd());
// Create workstream foo with its own STATE.md (full sections)
const wsDir = path.join(tmpDir, '.planning', 'workstreams', 'foo');
fs.mkdirSync(wsDir, { recursive: true });
const wsStatePath = path.join(wsDir, 'STATE.md');
fs.writeFileSync(wsStatePath, buildFullStateMd());
});
afterEach(() => {
cleanup(tmpDir);
});
test('record-metric --ws foo writes to workstream STATE.md, not root', () => {
const result = runGsdTools(
['state', 'record-metric', '--phase', '1', '--plan', '1', '--duration', '10min', '--ws', 'foo'],
tmpDir,
);
assert.ok(result.success, `record-metric --ws foo must succeed, got: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.strictEqual(parsed.recorded, true, `recorded must be true`);
// Workstream STATE.md should have the row; root STATE.md should NOT
const rootGet = runGsdTools(['state', 'get', 'Performance Metrics'], tmpDir);
assert.ok(rootGet.success, `state get root must succeed, got: ${rootGet.error}`);
const rootContent = JSON.parse(rootGet.output)['Performance Metrics'] || '';
assert.ok(
!rootContent.includes('Phase 1 P1'),
`Root STATE.md must NOT have the metric row. Got: ${rootContent}`,
);
const wsGet = runGsdTools(['state', 'get', 'Performance Metrics', '--ws', 'foo'], tmpDir);
assert.ok(wsGet.success, `state get --ws foo must succeed, got: ${wsGet.error}`);
const wsContent = JSON.parse(wsGet.output)['Performance Metrics'] || '';
assert.ok(
wsContent.includes('Phase 1 P1'),
`Workstream foo STATE.md must have the metric row. Got: ${wsContent}`,
);
});
test('add-decision --ws foo writes to workstream STATE.md, not root', () => {
const summary = 'Adopt event-sourcing for audit trail';
const result = runGsdTools(
['state', 'add-decision', '--phase', '1', '--summary', summary, '--ws', 'foo'],
tmpDir,
);
assert.ok(result.success, `add-decision --ws foo must succeed, got: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.strictEqual(parsed.added, true, `added must be true`);
// Root STATE.md must NOT have the decision
const rootGet = runGsdTools(['state', 'get', 'Decisions'], tmpDir);
assert.ok(rootGet.success, `state get root must succeed, got: ${rootGet.error}`);
const rootContent = JSON.parse(rootGet.output)['Decisions'] || '';
assert.ok(
!rootContent.includes(summary),
`Root STATE.md must NOT have the decision. Got: ${rootContent}`,
);
// Workstream STATE.md must have the decision
const wsGet = runGsdTools(['state', 'get', 'Decisions', '--ws', 'foo'], tmpDir);
assert.ok(wsGet.success, `state get --ws foo must succeed, got: ${wsGet.error}`);
const wsContent = JSON.parse(wsGet.output)['Decisions'] || '';
assert.ok(
wsContent.includes(summary),
`Workstream foo STATE.md must have the decision. Got: ${wsContent}`,
);
});
test('record-metric --ws foo auto-creates section in workstream STATE.md when missing', () => {
// Create a workstream without Performance Metrics section
const wsDir = path.join(tmpDir, '.planning', 'workstreams', 'bar');
fs.mkdirSync(wsDir, { recursive: true });
fs.writeFileSync(path.join(wsDir, 'STATE.md'), buildBareboneStateMd());
const result = runGsdTools(
['state', 'record-metric', '--phase', '1', '--plan', '1', '--duration', '5min', '--ws', 'bar'],
tmpDir,
);
assert.ok(result.success, `record-metric --ws bar must succeed, got: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.strictEqual(parsed.recorded, true, `recorded must be true`);
assert.strictEqual(parsed.created, true, `created must be true when section auto-created in workstream`);
// Root STATE.md must remain untouched
const rootGet = runGsdTools(['state', 'get', 'Performance Metrics'], tmpDir);
assert.ok(rootGet.success);
const rootContent = JSON.parse(rootGet.output)['Performance Metrics'] || '';
assert.ok(
!rootContent.includes('Phase 1 P1'),
`Root STATE.md must not be written when --ws bar is used`,
);
});
});

View File

@@ -1,127 +0,0 @@
'use strict';
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
function seedState(tmpDir, planLine = '1 of 2') {
const state = `# Project State
**Status:** executing
**Current Phase:** 1
## Current Position
Phase: 1 of 1
Plan: ${planLine}
Status: Ready
Last activity: 2026-01-01
Budget: $2,500 max test
## Session Continuity
Last session: 2026-01-01
`;
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), state, 'utf8');
}
function parseStateFile(tmpDir) {
const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf8');
const sections = {};
const keyCountsBySection = {};
let currentSection = '__root__';
sections[currentSection] = {};
keyCountsBySection[currentSection] = {};
for (const rawLine of content.split(/\r?\n/u)) {
const headingMatch = /^##\s+(.+)$/u.exec(rawLine);
if (headingMatch) {
currentSection = headingMatch[1].trim();
sections[currentSection] = sections[currentSection] || {};
keyCountsBySection[currentSection] = keyCountsBySection[currentSection] || {};
continue;
}
const trimmed = rawLine.trim();
if (!trimmed) continue;
const boldFieldMatch = /^\*\*([^*]+)\*\*:\s*(.*)$/u.exec(trimmed);
if (boldFieldMatch) {
const key = boldFieldMatch[1].trim();
const value = boldFieldMatch[2].trim();
sections[currentSection][key] = value;
keyCountsBySection[currentSection][key] = (keyCountsBySection[currentSection][key] || 0) + 1;
continue;
}
const colonIndex = trimmed.indexOf(':');
if (colonIndex <= 0) continue;
const key = trimmed.slice(0, colonIndex).trim();
const value = trimmed.slice(colonIndex + 1).trim();
sections[currentSection][key] = value;
keyCountsBySection[currentSection][key] = (keyCountsBySection[currentSection][key] || 0) + 1;
}
return { content, sections, keyCountsBySection };
}
describe('bug #3454: state mutation must preserve literal $N amounts', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject('bug-3454-');
});
afterEach(() => {
cleanup(tmpDir);
});
test('state advance-plan keeps Current Position dollar amount literal', () => {
seedState(tmpDir, '1 of 20');
const result = runGsdTools(['state', 'advance-plan'], tmpDir);
assert.equal(result.success, true, `state advance-plan failed: ${result.error || result.output}`);
const parsed = parseStateFile(tmpDir);
const currentPosition = parsed.sections['Current Position'] || {};
assert.equal(currentPosition.Budget, '$2,500 max test');
assert.equal((parsed.keyCountsBySection['Current Position'] || {}).Budget, 1);
});
test('state begin-phase keeps Current Position dollar amount literal', () => {
seedState(tmpDir);
const result = runGsdTools(['state', 'begin-phase', '--phase', '1', '--name', 'setup', '--plans', '2'], tmpDir);
assert.equal(result.success, true, `state begin-phase failed: ${result.error || result.output}`);
const parsed = parseStateFile(tmpDir);
const currentPosition = parsed.sections['Current Position'] || {};
assert.equal(currentPosition.Budget, '$2,500 max test');
assert.equal((parsed.keyCountsBySection['Current Position'] || {}).Budget, 1);
});
test('state complete-phase keeps Current Position dollar amount literal', () => {
seedState(tmpDir);
const result = runGsdTools(['state', 'complete-phase', '--phase', '1'], tmpDir);
assert.equal(result.success, true, `state complete-phase failed: ${result.error || result.output}`);
const parsed = parseStateFile(tmpDir);
const currentPosition = parsed.sections['Current Position'] || {};
assert.equal(currentPosition.Budget, '$2,500 max test');
assert.equal((parsed.keyCountsBySection['Current Position'] || {}).Budget, 1);
});
test('repeated state advance-plan stays size-bounded with dollar amounts', () => {
seedState(tmpDir, '1 of 20');
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
let stabilizedSize = null;
for (let i = 0; i < 8; i += 1) {
const result = runGsdTools(['state', 'advance-plan'], tmpDir);
assert.equal(result.success, true, `iteration ${i + 1} failed: ${result.error || result.output}`);
if (i === 0) stabilizedSize = fs.statSync(statePath).size;
}
const endSize = fs.statSync(statePath).size;
const growth = endSize / stabilizedSize;
assert.ok(growth <= 1.5, `expected <=1.5x growth after first write, got ${growth.toFixed(2)}x (${stabilizedSize} -> ${endSize})`);
});
});

View File

@@ -1,124 +0,0 @@
'use strict';
// allow-test-rule: source-text-is-the-product
// State.md is the deployed artifact; asserting on its literal text content
// tests the deployed contract.
/**
* Regression test for #3489
*
* `gsd state complete-phase --phase <N>` was non-idempotent. Re-invoking it
* on a phase already marked complete in STATE.md silently rolled STATE.md
* back to that phase's moment-of-completion — overwriting Status, Last
* Activity, Current Position and the body Status/Phase with stale values
* derived from the just-completed phase.
*
* Expected: when the target phase is already marked complete (and STATE.md
* has clearly advanced past it — e.g. a later phase is now in progress or
* inserted), `complete-phase` must be a no-op. No STATE.md write at all.
*/
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
describe('bug #3489: state complete-phase must be idempotent', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject('bug-3489-');
});
afterEach(() => {
cleanup(tmpDir);
});
test('re-running complete-phase on an already-complete phase does not roll STATE.md back', () => {
// STATE.md as it would appear AFTER phase 02.2 was legitimately completed
// AND a follow-up Phase 02.2.1 has since been inserted as in-progress.
// Re-invoking `state complete-phase --phase 02.2` from a downstream tool
// (e.g. a re-run of /gsd-execute-phase) must NOT regress this content.
const stateMd = [
'---',
'milestone: v1.0',
'---',
'',
'# State',
'',
'**Status:** in-progress',
'**Current Phase:** 02.2.1',
'**Last Activity:** 2026-05-13',
'**Last Activity Description:** Phase 02.2.1 inserted (urgent — gates Phase 5)',
'',
'## Current Position',
'',
'Phase: 02.2.1 — Not planned yet',
'Status: Phase 02.2.1 inserted (urgent — gates Phase 5)',
'Last activity: 2026-05-13 -- Phase 02.2.1 inserted (urgent — gates Phase 5)',
'',
].join('\n');
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, stateMd, 'utf8');
const before = fs.readFileSync(statePath, 'utf8');
const result = runGsdTools(['state', 'complete-phase', '--phase', '02.2'], tmpDir);
assert.ok(result.success, `command should not error, got: ${result.error || result.output}`);
const after = fs.readFileSync(statePath, 'utf8');
// Hard assertion: file is byte-identical to its pre-call snapshot.
assert.equal(
after,
before,
`STATE.md must not be rewritten when phase is already complete.\n\n--- before ---\n${before}\n--- after ---\n${after}`,
);
// Output should advertise the no-op so downstream consumers can detect it.
let payload = null;
try { payload = JSON.parse(result.output); } catch (_) { /* ignore */ }
assert.ok(payload && typeof payload === 'object', `expected JSON payload, got: ${result.output}`);
assert.deepEqual(payload.updated, [], `expected empty updated list, got: ${JSON.stringify(payload.updated)}`);
assert.equal(payload.phase, '02.2');
assert.equal(payload.idempotent, true, `expected idempotent:true flag, got: ${JSON.stringify(payload)}`);
});
test('completing the currently in-progress phase still works normally (no false-positive idempotency)', () => {
// Sanity check: the guard must not fire on the legitimate first completion.
const stateMd = [
'---',
'milestone: v1.0',
'---',
'',
'# State',
'',
'**Status:** in-progress',
'**Current Phase:** 03',
'**Last Activity:** 2026-05-13',
'',
'## Current Position',
'',
'Phase: 03',
'Status: Phase 03 executing',
'',
].join('\n');
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, stateMd, 'utf8');
const result = runGsdTools(['state', 'complete-phase', '--phase', '03'], tmpDir);
assert.ok(result.success, `command failed: ${result.error || result.output}`);
const after = fs.readFileSync(statePath, 'utf8');
assert.ok(
after.includes('**Status:** Phase 03 complete'),
`expected Status updated to "Phase 03 complete", got:\n${after}`,
);
const payload = JSON.parse(result.output);
assert.notEqual(payload.idempotent, true, 'first completion must not be flagged idempotent');
assert.ok(Array.isArray(payload.updated) && payload.updated.length > 0, 'expected non-empty updated list');
});
});

View File

@@ -1,379 +0,0 @@
/**
* Regression tests for bug #3537
*
* Phase state verbs must match a canonical phase id against ROADMAP.md prose
* regardless of zero-padding on either side: the skills pass the padded form
* (`02.7`) after resolving the phase directory, but human-authored ROADMAP
* prose is conventionally un-padded (`### Phase 2.7:`, `- [ ] **Phase 2.7:**`).
*
* v1.42.1 added `phaseMarkdownRegexSource()` which renders `0*<integer><...>`
* — padding-tolerant on both sides — but wired it into only 1 of 8 call sites.
* The other 7 used raw `escapeRegex(phaseNum)` or `0*${escapeRegex(...)}`
* (tolerated extra padding, not missing), so passing the padded form silently
* no-op'd and the verbs returned success while ROADMAP.md was unchanged.
*
* Parity assertion (per CONTEXT.md DEFECT.GENERATIVE-FIX): for each verb,
* running with the padded form must produce the same ROADMAP.md as running
* with the un-padded form against an identical fixture. Per-site fixes
* without a parity test let the next call-site drift back undetected.
*/
'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { execFileSync } = require('node:child_process');
const { cleanup } = require('./helpers.cjs');
const gsdTools = path.resolve(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
function run(args, cwd) {
try {
return {
stdout: execFileSync('node', [gsdTools, ...args], {
cwd,
timeout: 15000,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
}),
ok: true,
};
} catch (e) {
return {
stdout: (e.stdout && e.stdout.toString()) || '',
stderr: (e.stderr && e.stderr.toString()) || '',
ok: false,
code: e.status,
};
}
}
/**
* Build a planning fixture with project_code='CK', padded phase directory
* (`CK-02.7-meta-lead-ads/`), and un-padded ROADMAP prose (`Phase 2.7`).
* This mirrors the reporter's environment in #3537 exactly.
*/
function setupFixture(tmpDir, opts = {}) {
const {
projectCode = 'CK',
paddedId = '02.7',
unpaddedId = '2.7',
extraPhases = [],
} = opts;
const planningDir = path.join(tmpDir, '.planning');
fs.mkdirSync(planningDir, { recursive: true });
fs.writeFileSync(
path.join(planningDir, 'config.json'),
JSON.stringify({ project_code: projectCode })
);
fs.writeFileSync(
path.join(planningDir, 'STATE.md'),
`---\ncurrent_phase: ${unpaddedId}\nstatus: executing\n---\n# State\n`
);
// Padded phase directory with one plan + matching summary so the phase
// is "complete" for phase-complete and update-plan-progress verbs.
const phaseDirName = `${projectCode}-${paddedId}-meta-lead-ads`;
const phaseDir = path.join(planningDir, 'phases', phaseDirName);
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(
path.join(phaseDir, `${paddedId}-01-PLAN.md`),
`---\nphase: ${unpaddedId}\nplan: 1\nwave: 1\n---\n# Plan 1\n`
);
fs.writeFileSync(
path.join(phaseDir, `${paddedId}-01-SUMMARY.md`),
'---\nstatus: complete\n---\n# Summary\nDone.'
);
fs.writeFileSync(
path.join(phaseDir, `${paddedId}-VERIFICATION.md`),
'---\nstatus: passed\nscore: "1/1"\n---\n# Verification\nPassed.\n'
);
const extra = extraPhases
.map((p) => `- [ ] **Phase ${p.id}: ${p.name}**`)
.join('\n');
const roadmap = [
'# Roadmap',
'',
'## v1.0 Milestone',
'',
`- [ ] **Phase ${unpaddedId}: Meta Lead Ads**`,
extra,
'',
'## Progress',
'',
'| Phase | Plans | Status | Completed |',
'|-------|-------|--------|-----------|',
`| ${unpaddedId} Meta Lead Ads | 0/1 | Planned | - |`,
'',
`### Phase ${unpaddedId}: Meta Lead Ads`,
'',
'**Goal:** ship the thing',
'**Plans:** 0 plans',
'',
'Plans:',
`- [ ] ${paddedId}-01-PLAN.md`,
'',
...extraPhases.flatMap((p) => [
`### Phase ${p.id}: ${p.name}`,
'',
'**Goal:** stub',
'**Plans:** 0 plans',
'',
'Plans:',
`- [ ] ${p.id}-01-PLAN.md`,
'',
]),
]
.filter((l) => l !== '')
.join('\n') + '\n';
fs.writeFileSync(path.join(planningDir, 'ROADMAP.md'), roadmap);
return {
planningDir,
roadmapPath: path.join(planningDir, 'ROADMAP.md'),
phaseDir,
};
}
/**
* Run a verb in two parallel fixtures — one passing the padded form, one
* passing the un-padded form — then compare the resulting ROADMAP.md bytes.
* Any divergence means the verb's regex did not tolerate padding on at least
* one side.
*/
function expectParity({ verbWithPadded, verbWithUnpadded, fixtureOpts }) {
const tmpA = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3537-A-'));
const tmpB = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3537-B-'));
try {
const a = setupFixture(tmpA, fixtureOpts);
const b = setupFixture(tmpB, fixtureOpts);
const ra = verbWithPadded(tmpA);
const rb = verbWithUnpadded(tmpB);
const aRoadmap = fs.readFileSync(a.roadmapPath, 'utf-8');
const bRoadmap = fs.readFileSync(b.roadmapPath, 'utf-8');
return { aRoadmap, bRoadmap, ra, rb };
} finally {
cleanup(tmpA);
cleanup(tmpB);
}
}
describe('bug #3537: phase verbs accept padded ids against un-padded ROADMAP prose', () => {
test('phase complete: padded 02.7 and un-padded 2.7 produce identical ROADMAP', () => {
const { aRoadmap, bRoadmap } = expectParity({
fixtureOpts: {},
verbWithPadded: (cwd) => run(['phase', 'complete', '02.7'], cwd),
verbWithUnpadded: (cwd) => run(['phase', 'complete', '2.7'], cwd),
});
assert.equal(
aRoadmap,
bRoadmap,
'padded `02.7` must mutate ROADMAP identically to un-padded `2.7`'
);
// And the canonical mutation must have actually happened (otherwise
// both forms could be silently no-op'ing and still produce identical
// output — a vacuous parity pass).
assert.match(
aRoadmap,
/- \[x\] \*\*Phase 2\.7:/,
'overview checkbox should be flipped under both invocations'
);
});
test('roadmap get-phase: padded 02.7 returns the same section as un-padded 2.7', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3537-get-'));
try {
setupFixture(tmp, {});
const padded = run(['roadmap', 'get-phase', '02.7', '--raw'], tmp);
const unpadded = run(['roadmap', 'get-phase', '2.7', '--raw'], tmp);
assert.equal(
padded.stdout,
unpadded.stdout,
'padded and un-padded ids must return identical sections'
);
// Non-vacuous guard: both forms must have actually returned a section
// (the bug we're fixing was that the padded form returned an empty
// string while reporting success).
assert.ok(
padded.stdout.trim().length > 0,
'verb must return non-empty section under both invocations'
);
} finally {
cleanup(tmp);
}
});
test('phase next-decimal: padded 02 finds decimals in un-padded ROADMAP', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3537-nd-'));
try {
setupFixture(tmp, {
paddedId: '02.7',
unpaddedId: '2.7',
});
// Padded base `02` must discover the existing decimal `2.7` from the
// un-padded heading and propose `2.8` (or higher) as next.
const padded = run(['phase', 'next-decimal', '02', '--raw'], tmp);
const unpadded = run(['phase', 'next-decimal', '2', '--raw'], tmp);
assert.equal(
padded.stdout,
unpadded.stdout,
'next-decimal must produce identical JSON for padded and un-padded base'
);
// Sanity: the existing 2.7 must be reflected. If the prose-scan regex
// silently failed to match `Phase 2.7`, the result would skip 2.7 and
// wrongly propose 2.1 as next. `phase next-decimal --raw` emits the
// next id as plain text (`02.8`), so trimmed string equality is the
// typed assertion shape (no raw-text regex matching — lint policy).
const nextDecimalPadded = padded.stdout.trim();
assert.notEqual(
nextDecimalPadded,
'02.1',
'must not propose 02.1 when 2.7 already exists in ROADMAP'
);
assert.notEqual(
nextDecimalPadded,
'2.1',
'must not propose 2.1 when 2.7 already exists in ROADMAP'
);
} finally {
cleanup(tmp);
}
});
test('phase insert: padded base 02 finds anchor in un-padded ROADMAP', () => {
const tmpA = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3537-ins-A-'));
const tmpB = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3537-ins-B-'));
try {
// Use a phase 2 (no decimal) base so insert proposes 2.1.
const optsA = {
paddedId: '02',
unpaddedId: '2',
};
setupFixture(tmpA, optsA);
setupFixture(tmpB, optsA);
const padded = run(['phase', 'insert', '02', 'urgent extension'], tmpA);
const unpadded = run(['phase', 'insert', '2', 'urgent extension'], tmpB);
// Both invocations should succeed (exit 0) — passing the padded base
// against un-padded prose used to error "Phase 02 not found".
assert.ok(
padded.ok,
`padded form must succeed, got code=${padded.code}, stderr=${padded.stderr}`
);
assert.ok(
unpadded.ok,
`un-padded form must succeed, got code=${unpadded.code}`
);
const aRoadmap = fs.readFileSync(
path.join(tmpA, '.planning', 'ROADMAP.md'),
'utf-8'
);
// The new header may be rendered as `Phase 02.1` or `Phase 2.1`
// (normalizePhaseName pads to 2 digits today; that is pre-existing
// behavior, not the subject of #3537). The critical assertion for
// this verb is "padded form found the anchor and the insertion
// happened" — full byte-parity is gated by an unrelated `Depends on:
// Phase ${afterPhase}` echo bug that lies outside #3537's scope.
assert.match(
aRoadmap,
/### Phase 0?2\.1: urgent extension/,
'padded form must insert the new decimal phase header'
);
// Reference `tmpB` to ensure cleanup runs and keep it alive in the
// closure — also a smoke-check that the un-padded sibling did not
// crash mid-run.
assert.ok(fs.existsSync(path.join(tmpB, '.planning', 'ROADMAP.md')));
} finally {
cleanup(tmpA);
cleanup(tmpB);
}
});
test('roadmap annotate-dependencies: padded 02.7 finds phase section', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3537-ann-'));
try {
const { roadmapPath } = setupFixture(tmp, {});
const before = fs.readFileSync(roadmapPath, 'utf-8');
const padded = run(
['roadmap', 'annotate-dependencies', '02.7'],
tmp
);
assert.ok(
padded.ok,
`padded form must succeed, got code=${padded.code}, stderr=${padded.stderr}`
);
const after = fs.readFileSync(roadmapPath, 'utf-8');
// The annotation may be a no-op if there's only one wave and no
// cross-cutting truths, but the verb must have reached the phase
// section. Confirm by running parity against un-padded form on a
// separate fixture and asserting equality.
const tmp2 = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3537-ann2-'));
try {
const { roadmapPath: rp2 } = setupFixture(tmp2, {});
run(['roadmap', 'annotate-dependencies', '2.7'], tmp2);
const unpadded = fs.readFileSync(rp2, 'utf-8');
assert.equal(
after,
unpadded,
'annotate-dependencies must produce identical output for padded and un-padded ids'
);
// And the verb must not have silently destroyed the file (sanity).
assert.match(after, /### Phase 2\.7:/, 'phase header must survive');
// Reference `before` to keep it from being dead-binding-flagged
// and to assert the run did not corrupt the rest of the file.
assert.ok(before.length > 0);
} finally {
cleanup(tmp2);
}
} finally {
cleanup(tmp);
}
});
test('roadmap update-plan-progress: control case — already wired in 1.42.1', () => {
// This is the one site already using phaseMarkdownRegexSource. Including
// it as a control proves the parity assertion is a meaningful signal
// (this test should pass on main, while the others fail).
const { aRoadmap, bRoadmap } = expectParity({
fixtureOpts: {},
verbWithPadded: (cwd) =>
run(['roadmap', 'update-plan-progress', '02.7'], cwd),
verbWithUnpadded: (cwd) =>
run(['roadmap', 'update-plan-progress', '2.7'], cwd),
});
assert.equal(
aRoadmap,
bRoadmap,
'control verb must already produce identical output (wired in 1.42.1)'
);
assert.match(
aRoadmap,
/- \[x\] \*\*Phase 2\.7:/,
'control verb must flip checkbox under both invocations'
);
});
});

View File

@@ -1,330 +0,0 @@
/**
* Regression tests for bug #3584 — runtime emitters use the slash formatter.
*
* These tests exercise the actual runtime command handlers via `runGsdTools`
* and assert on the structured payloads they emit/persist. They prove that
* the high-impact emitters identified in the issue (`init.cjs` recommended
* actions, `phase.cjs` ROADMAP persistence, `verify.cjs` remediation hints,
* `milestone.cjs` Operator-Next-Steps persistence, `validate-command-router`
* fracture recommendations) no longer emit the unroutable `/gsd:<cmd>` colon
* form for skills-based runtimes.
*/
'use strict';
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const {
runGsdTools,
createTempProject,
createTempDir,
cleanup,
} = require('./helpers.cjs');
// Helper: assert no string field anywhere in `value` (recursive) contains '/gsd:'.
function assertNoColonForm(value, label) {
const stack = [{ v: value, p: label }];
while (stack.length > 0) {
const { v, p } = stack.pop();
if (typeof v === 'string') {
assert.ok(
!v.includes('/gsd:'),
`${p}: must not contain deprecated /gsd: form, got ${JSON.stringify(v)}`,
);
} else if (Array.isArray(v)) {
v.forEach((item, i) => stack.push({ v: item, p: `${p}[${i}]` }));
} else if (v && typeof v === 'object') {
for (const key of Object.keys(v)) {
stack.push({ v: v[key], p: `${p}.${key}` });
}
}
}
}
// Helper: collect every `command` string under a recommendedActions-style array.
function collectCommandFields(value) {
const out = [];
const stack = [value];
while (stack.length > 0) {
const v = stack.pop();
if (Array.isArray(v)) {
for (const item of v) stack.push(item);
} else if (v && typeof v === 'object') {
if (typeof v.command === 'string') out.push(v.command);
for (const key of Object.keys(v)) stack.push(v[key]);
}
}
return out;
}
describe('bug-3584: init manager recommendedActions emit hyphen form', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
// Minimal ROADMAP + STATE so init manager will run.
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'# Roadmap',
'',
'## Milestone v1',
'',
'### Phase 1: Foundation',
'**Goal:** scaffold',
'**Plans:** 1 plan',
'',
].join('\n'),
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'# State\n\nCurrent Phase: 1\n',
);
});
afterEach(() => cleanup(tmpDir));
test('skills runtime (claude) emits /gsd-<cmd> in recommended_actions[].command', () => {
// Plan-but-not-executed: directory exists with a PLAN.md → execute is recommended.
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
const result = runGsdTools('init manager', tmpDir, { GSD_RUNTIME: 'claude' });
assert.ok(result.success, `init manager failed: ${result.error || result.output}`);
const payload = JSON.parse(result.output);
const commands = collectCommandFields(payload.recommended_actions || []);
assert.ok(commands.length > 0, 'recommended_actions should produce at least one command');
for (const cmd of commands) {
assert.ok(
cmd.startsWith('/gsd-'),
`recommended_actions command must start with /gsd- for skills-based runtimes, got ${cmd}`,
);
assert.ok(
!cmd.includes('/gsd:'),
`recommended_actions command must not contain /gsd: colon form, got ${cmd}`,
);
}
assertNoColonForm(payload, 'init.manager payload');
});
test('codex runtime emits $gsd-<cmd> in recommended_actions[].command', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
const result = runGsdTools('init manager', tmpDir, { GSD_RUNTIME: 'codex' });
assert.ok(result.success, `init manager (codex) failed: ${result.error || result.output}`);
const payload = JSON.parse(result.output);
const commands = collectCommandFields(payload.recommended_actions || []);
assert.ok(commands.length > 0);
for (const cmd of commands) {
assert.ok(
cmd.startsWith('$gsd-'),
`codex recommended_actions command must use $gsd- shell-var form, got ${cmd}`,
);
}
});
test('codex alias runtime emits $gsd-<cmd> in recommended_actions[].command', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
const result = runGsdTools('init manager', tmpDir, { GSD_RUNTIME: 'codex-app' });
assert.ok(result.success, `init manager (codex-app) failed: ${result.error || result.output}`);
const payload = JSON.parse(result.output);
const commands = collectCommandFields(payload.recommended_actions || []);
assert.ok(commands.length > 0);
for (const cmd of commands) {
assert.ok(
cmd.startsWith('$gsd-'),
`codex alias recommended_actions command must use $gsd- shell-var form, got ${cmd}`,
);
}
});
});
describe('bug-3584: phase add persists hyphen form into ROADMAP.md', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n## Milestone v1\n\n',
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'# State\n',
);
});
afterEach(() => cleanup(tmpDir));
test('phase add persists hyphen-form slash command in roadmap get-phase payload', () => {
// 1. Add a phase — the system under test persists the phase entry into ROADMAP.md.
const addResult = runGsdTools(
['phase', 'add', 'Test new feature'],
tmpDir,
{ GSD_RUNTIME: 'claude' },
);
assert.ok(addResult.success, `phase add failed: ${addResult.error || addResult.output}`);
const addPayload = JSON.parse(addResult.output);
assert.ok(addPayload.phase_number, 'phase add must return phase_number');
// 2. Read the persisted section back via the structured roadmap-get-phase
// contract (NOT via readFileSync; the `section` field on the JSON payload
// is the runtime's typed projection of the on-disk ROADMAP content).
const getResult = runGsdTools(
['roadmap', 'get-phase', String(addPayload.phase_number)],
tmpDir,
{ GSD_RUNTIME: 'claude' },
);
assert.ok(getResult.success, `roadmap get-phase failed: ${getResult.error || getResult.output}`);
const getPayload = JSON.parse(getResult.output);
assert.ok(getPayload.found, 'roadmap get-phase must find the newly-added phase');
assert.ok(typeof getPayload.section === 'string', 'roadmap get-phase must return a section field');
// 3. The persisted phase section must use the routable hyphen form.
assert.ok(
!getPayload.section.includes('/gsd:plan-phase'),
`persisted phase section must not contain /gsd:plan-phase, got: ${getPayload.section}`,
);
assert.ok(
getPayload.section.includes('/gsd-plan-phase'),
`persisted phase section must contain /gsd-plan-phase, got: ${getPayload.section}`,
);
});
});
describe('bug-3584: validate health emits hyphen form in fix hints', () => {
test('validate health on a broken project returns hyphen-form fix strings', (t) => {
const tmpDir = createTempDir();
t.after(() => cleanup(tmpDir));
// No .planning directory → E001 fires with a `fix:` string that contains
// the slash-command form.
const result = runGsdTools(
['validate', 'health'],
tmpDir,
{ GSD_RUNTIME: 'claude' },
);
// validate health exits non-zero on broken projects but still emits JSON to stdout.
const stdout = result.output;
let payload;
try {
payload = JSON.parse(stdout);
} catch (e) {
throw new Error(`validate health did not emit parseable JSON: ${stdout.slice(0, 400)}`);
}
const allIssues = []
.concat(payload.errors || [])
.concat(payload.warnings || [])
.concat(payload.info || []);
assert.ok(allIssues.length > 0, 'validate health on a bare tmpdir should report at least one issue');
const fixesWithSlash = allIssues
.map((i) => i.fix)
.filter((f) => typeof f === 'string' && /gsd[-:]/.test(f));
assert.ok(
fixesWithSlash.length > 0,
'at least one fix hint should reference a /gsd- slash command',
);
for (const fix of fixesWithSlash) {
assert.ok(
!fix.includes('/gsd:'),
`validate health fix hint must not contain /gsd: colon form, got ${JSON.stringify(fix)}`,
);
}
assertNoColonForm(payload, 'validate health payload');
});
});
describe('bug-3584: validate context recommendation uses hyphen form', () => {
test('warning/critical recommendations emit /gsd-thread', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
// Critical band: 75% utilization. validate context emits JSON only with --json.
const result = runGsdTools(
['validate', 'context', '--tokens-used', '75000', '--context-window', '100000', '--json'],
tmpDir,
{ GSD_RUNTIME: 'claude' },
);
assert.ok(result.success, `validate context failed: ${result.error || result.output}`);
const payload = JSON.parse(result.output);
assert.ok(payload.recommendation, 'critical utilization should produce a recommendation');
assert.ok(
!payload.recommendation.includes('/gsd:'),
`recommendation must not contain /gsd:, got ${JSON.stringify(payload.recommendation)}`,
);
assert.ok(
payload.recommendation.includes('/gsd-thread'),
`recommendation must reference /gsd-thread, got ${JSON.stringify(payload.recommendation)}`,
);
});
});
describe('bug-3584: validate health uses formatter for codex runtime too', () => {
test('validate health under codex emits $gsd-<cmd> in fix strings (positive assertion)', (t) => {
const tmpDir = createTempDir();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(
['validate', 'health'],
tmpDir,
{ GSD_RUNTIME: 'codex' },
);
let payload;
try {
payload = JSON.parse(result.output);
} catch (e) {
throw new Error(`validate health (codex) did not emit JSON: ${result.output.slice(0, 400)}`);
}
const allIssues = []
.concat(payload.errors || [])
.concat(payload.warnings || [])
.concat(payload.info || []);
// Collect fixes that mention any gsd slash-command form so we can lock
// both the absence of legacy forms AND the presence of the codex shape.
const fixesWithGsdRef = allIssues
.map((i) => i.fix)
.filter((f) => typeof f === 'string' && /(?:\$|\/)gsd[-:]/.test(f));
assert.ok(
fixesWithGsdRef.length > 0,
'validate health on a bare tmpdir must produce at least one fix hint referencing a gsd command',
);
for (const fix of fixesWithGsdRef) {
assert.ok(
fix.includes('$gsd-'),
`codex validate health fix must use shell-var $gsd- form, got ${JSON.stringify(fix)}`,
);
assert.ok(
!fix.includes('/gsd:'),
`codex validate health fix must not contain /gsd: colon form, got ${JSON.stringify(fix)}`,
);
assert.ok(
!fix.includes('/gsd-'),
`codex validate health fix must not contain /gsd- (skills) form, got ${JSON.stringify(fix)}`,
);
}
});
});

View File

@@ -1,167 +0,0 @@
/**
* Bug #3599: roadmap.get-phase no longer matches custom phase IDs with
* project-code prefixes like `PROJ-42`.
*
* `phaseMarkdownRegexSource(phaseNum)` in gsd-core/bin/lib/core.cjs
* (and its SDK twin in sdk/src/query/roadmap-update-plan-progress.ts) strips
* the `PROJ-` prefix before building the padding-tolerant numeric regex.
* Result: `roadmap get-phase PROJ-42` produces a regex of `0*42`, which
* matches `### Phase 42:` instead of (or in addition to) the intended
* `### Phase PROJ-42:`. The function's own docstring promises a fallback to
* `escapeRegex(phaseNum)` for non-numeric custom IDs, but that branch is
* unreachable for project-code-prefixed numeric IDs.
*
* Fix: the emitted regex must match BOTH the stripped numeric form (so
* `CK-01-name` directory inputs still resolve to `Phase 1:` in prose, the
* #3537 contract) AND the full prefixed form (so `PROJ-42` resolves to
* `Phase PROJ-42:`).
*/
'use strict';
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
function writeRoadmap(tmpDir, body) {
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), body);
}
function writeState(tmpDir, version) {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`---\nmilestone: ${version}\n---\n`,
);
}
describe('bug #3599: roadmap get-phase preserves project-code prefix in lookup', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject('bug-3599-');
});
afterEach(() => {
cleanup(tmpDir);
});
test('finds ### Phase PROJ-42: when queried as PROJ-42', () => {
writeState(tmpDir, 'v1.0.0');
writeRoadmap(
tmpDir,
[
'# Roadmap',
'',
'## Current Milestone: v1.0.0 - Test',
'',
'### Phase PROJ-42: Custom phase',
'**Goal:** Verify project-code-prefixed lookup',
'',
].join('\n'),
);
const result = runGsdTools('roadmap get-phase PROJ-42 --json', tmpDir);
assert.ok(result.success, `command failed: ${result.error || result.output}`);
const payload = JSON.parse(result.output);
assert.strictEqual(payload.found, true, `expected found=true, got: ${result.output}`);
assert.strictEqual(payload.phase_name, 'Custom phase');
assert.strictEqual(payload.goal, 'Verify project-code-prefixed lookup');
});
test('does NOT cross-match: querying 42 must not match ### Phase PROJ-42:', () => {
// Counter-test: if the regex erroneously matches both forms in both
// directions, this catches it. `42` must only match `Phase 42:` — not
// `Phase PROJ-42:` — otherwise integer phase lookups silently steal
// matches from prefixed siblings.
writeState(tmpDir, 'v1.0.0');
writeRoadmap(
tmpDir,
[
'# Roadmap',
'',
'## Current Milestone: v1.0.0 - Test',
'',
'### Phase PROJ-42: Should not be returned for `42`',
'**Goal:** Counter-test',
'',
].join('\n'),
);
const result = runGsdTools('roadmap get-phase 42 --json', tmpDir);
assert.ok(result.success);
const payload = JSON.parse(result.output);
assert.strictEqual(
payload.found,
false,
`bare numeric '42' must not match 'Phase PROJ-42:'; got ${result.output}`,
);
});
test('preserves #3537 contract: CK-01 directory form resolves to Phase 1 prose', () => {
// Existing contract: phase directory names like `CK-01-name` carry the
// project_code prefix and a zero-padded number, but ROADMAP prose is
// typically un-padded (`### Phase 1:`). The padding-tolerant lookup must
// still bridge those two surfaces.
writeState(tmpDir, 'v1.0.0');
writeRoadmap(
tmpDir,
[
'# Roadmap',
'',
'## Current Milestone: v1.0.0 - Test',
'',
'### Phase 1: Numeric prose',
'**Goal:** #3537 contract — CK-01 dir → Phase 1 prose',
'',
].join('\n'),
);
const result = runGsdTools('roadmap get-phase CK-01 --json', tmpDir);
assert.ok(result.success);
const payload = JSON.parse(result.output);
assert.strictEqual(
payload.found,
true,
`CK-01 must still resolve to 'Phase 1:' prose (#3537 contract); got ${result.output}`,
);
assert.strictEqual(payload.phase_name, 'Numeric prose');
});
test('finds the right phase when both prefixed and bare forms coexist', () => {
// Disambiguation test: a roadmap that contains BOTH `### Phase 42:` and
// `### Phase PROJ-42:` must resolve each query to its specific match.
writeState(tmpDir, 'v1.0.0');
writeRoadmap(
tmpDir,
[
'# Roadmap',
'',
'## Current Milestone: v1.0.0 - Test',
'',
'### Phase 42: Bare numeric',
'**Goal:** Bare',
'',
'### Phase PROJ-42: Prefixed',
'**Goal:** Prefixed',
'',
].join('\n'),
);
const r42 = runGsdTools('roadmap get-phase 42 --json', tmpDir);
const rProj = runGsdTools('roadmap get-phase PROJ-42 --json', tmpDir);
const p42 = JSON.parse(r42.output);
const pProj = JSON.parse(rProj.output);
assert.strictEqual(p42.found, true);
assert.strictEqual(p42.phase_name, 'Bare numeric');
assert.strictEqual(p42.goal, 'Bare');
assert.strictEqual(pProj.found, true);
assert.strictEqual(pProj.phase_name, 'Prefixed');
assert.strictEqual(pProj.goal, 'Prefixed');
});
});

View File

@@ -1,135 +0,0 @@
'use strict';
/**
* Regression tests for #3631 — SDK dispatch path in family routers must
* forward the `--raw` flag through to `output()`.
*
* Before the fix, every `*-command-router.cjs` `sdkHandler` called
* `output(result.data)` without the second positional `raw` argument or the
* third positional `rawValue`. With `--raw` set, the SDK path therefore
* emitted JSON-stringified data ({"next":"2.1",...}) instead of the scalar
* the CJS path used to print (e.g. `2.1`).
*
* Both tests below exercise the live SDK path:
* 1. `phase next-decimal --raw <base>` must emit the next-decimal token.
* 2. `roadmap get-phase --raw <id>` must emit the phase's roadmap section.
*
* Per CONTRIBUTING.md: assertions are on structured (scalar) tokens, not
* substring grep against full JSON.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { execFileSync } = require('node:child_process');
const { cleanup } = require('./helpers.cjs');
const GSD_TOOLS = path.resolve(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
function run(args, cwd) {
try {
return {
ok: true,
stdout: execFileSync(process.execPath, [GSD_TOOLS, ...args], {
cwd,
encoding: 'utf-8',
timeout: 15000,
}),
};
} catch (e) {
return {
ok: false,
stdout: (e.stdout && e.stdout.toString()) || '',
stderr: (e.stderr && e.stderr.toString()) || '',
code: e.status,
};
}
}
function makeFixture() {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3631-'));
const planning = path.join(tmp, '.planning');
fs.mkdirSync(path.join(planning, 'phases'), { recursive: true });
fs.writeFileSync(
path.join(planning, 'ROADMAP.md'),
[
'# Project Roadmap',
'',
'## v1',
'',
'### Phase 1: First',
'',
'Body of phase 1.',
'',
'### Phase 2: Second',
'',
'Body of phase 2.',
'',
].join('\n')
);
// PROJECT.md anchors the planning root for callers that resolve it.
fs.writeFileSync(path.join(planning, 'PROJECT.md'), '# Test\n');
return tmp;
}
describe('bug #3631 — SDK family routers forward --raw to output()', () => {
test('phase next-decimal --raw emits the scalar next-decimal token (not JSON)', () => {
const tmp = makeFixture();
try {
const res = run(['phase', 'next-decimal', '--raw', '1'], tmp);
assert.ok(
res.ok,
`command must succeed; got code=${res.code} stderr=${res.stderr}`
);
const trimmed = res.stdout.trim();
// Scalar form — must be a phase id token like "1.1", not a JSON object.
assert.doesNotMatch(
trimmed,
/^\{/,
`--raw must not emit JSON; got: ${trimmed}`
);
assert.match(
trimmed,
/^0*\d+(?:\.\d+)?$/,
`--raw must emit a scalar phase id; got: ${trimmed}`
);
// SDK and CJS both normalize the base phase before computing the next-
// decimal token; CJS emits "1.1" while SDK normalizes "1"→"01" and emits
// "01.1". Both are valid scalar projections — assert on parity with the
// computed-next semantics rather than the exact padding form.
assert.ok(
trimmed === '1.1' || trimmed === '01.1',
`expected next-decimal of base "1" to be 1.1 or 01.1; got: ${trimmed}`
);
} finally {
cleanup(tmp);
}
});
test('roadmap get-phase --raw emits the phase section (not JSON)', () => {
const tmp = makeFixture();
try {
const res = run(['roadmap', 'get-phase', '--raw', '2'], tmp);
assert.ok(
res.ok,
`command must succeed; got code=${res.code} stderr=${res.stderr}`
);
const trimmed = res.stdout.trim();
assert.doesNotMatch(
trimmed,
/^\{/,
`--raw must not emit JSON; got: ${trimmed.slice(0, 80)}`
);
// Section text starts with the heading.
assert.match(
trimmed,
/Phase 2:\s*Second/,
`--raw must emit the section body containing the Phase 2 heading; got: ${trimmed.slice(0, 80)}`
);
} finally {
cleanup(tmp);
}
});
});

View File

@@ -1,507 +0,0 @@
'use strict';
// allow-test-rule: source-text-is-the-product
// Reads .md/.json/.yml product files whose deployed text IS what the
// runtime loads — testing text content tests the deployed contract.
/**
* Regression — issue #3691
*
* Two regex defects fixed in `roadmap.cjs` function `cmdRoadmapAnnotateDependencies`,
* plus two review-cycle additions (F3 defensive guard, F4 adversarial gaps):
*
* Bug 1 (line ~553) — Plans-block detection regex `/(Plans:\s*\n)/i` requires no text
* after the colon. Headers like `Plans: 3 plans across 2 waves\n` or
* `**Plans:** 3 plans\n` are silently skipped and the function early-returns.
* Fix: `(?:^|\n)(\*{0,2}Plans\*{0,2}:[^\n]*\n)` anchors to start-of-line and
* accepts optional bold wrappers and any trailing text on the header line.
*
* Bug 3 (line ~566) — Plan-ID extraction regex `/([\w-]+?)/` excludes `.`, so
* decimal plan IDs like `02.3-01` are captured as `02` only, never match
* the planData entry, and every plan defaults to wave 1.
* Fix: `[\w.-]+?` includes `.` so decimal IDs are captured in full.
*
* Note: "Bug 2" (phase-section boundary `\d` → `\d[\d.]*`) was confirmed empirically
* to be a no-op: any phase heading starts with a digit, so `\d` already matches.
* The no-op change was dropped from the PR; this file has no Bug 2 describe block.
*
* Review additions:
* F3 — Leading-dot plan ID guard: malformed IDs like `.invalid` are rejected
* before planData.find() rather than silently defaulting to wave 1.
* F4 — Adversarial gaps: multi-decimal leading-zero IDs (001.10-PLAN.md) and
* bare-bold `**Plans:**` (no trailing text) are explicitly covered.
*/
const { test, describe, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
// ---------------------------------------------------------------------------
// Shared test helpers
// ---------------------------------------------------------------------------
function makePlanProject(files = {}) {
const dir = createTempProject();
fs.mkdirSync(path.join(dir, '.planning', 'phases'), { recursive: true });
for (const [rel, content] of Object.entries(files)) {
const abs = path.join(dir, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content, 'utf-8');
}
return dir;
}
/** Build a minimal PLAN.md frontmatter string */
function makePlan({ phase, plan, wave, dependsOn = [] }) {
return [
'---',
`phase: "${phase}"`,
`plan: "${plan}"`,
'type: standard',
`wave: ${wave}`,
`depends_on: [${dependsOn.map(d => `"${d}"`).join(', ')}]`,
'files_modified: []',
'autonomous: true',
'must_haves:',
' truths: []',
' artifacts: []',
' key_links: []',
'---',
'',
`<objective>Plan ${plan}</objective>`,
'',
].join('\n');
}
// ---------------------------------------------------------------------------
// Bug 1 — Plans-block detection: inline summary text after the colon
// ---------------------------------------------------------------------------
describe('bug #3691 — Bug 1: Plans-block detection with inline summary', () => {
let tmpDir;
afterEach(() => cleanup(tmpDir));
test('Plans: N plans (inline count after colon) is detected as a Plans-block', (_t) => {
// Pre-fix: `Plans:\s*\n` requires bare newline — fails for "Plans: 2 plans\n"
// Post-fix: `Plans:[^\n]*\n` accepts any text after the colon
const roadmap = [
'# Roadmap',
'',
'### Phase 1: Foundation',
'',
'**Goal:** Set up project',
'',
'Plans: 2 plans',
'- [ ] 01-01-PLAN.md — Task A',
'- [ ] 01-02-PLAN.md — Task B',
'',
].join('\n');
tmpDir = makePlanProject({
'.planning/ROADMAP.md': roadmap,
'.planning/phases/01-foundation/01-01-PLAN.md': makePlan({ phase: '1', plan: '01-01', wave: 1 }),
'.planning/phases/01-foundation/01-02-PLAN.md': makePlan({ phase: '1', plan: '01-02', wave: 2, dependsOn: ['01-01'] }),
});
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.updated, true,
'Plans-block with inline summary must be detected and written');
assert.ok(out.waves >= 1, 'at least one wave must be written');
const written = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(written.includes('Wave'), 'wave annotation must appear in ROADMAP.md');
});
test('Plans: N plans across N waves (longer inline text) is detected', (_t) => {
const roadmap = [
'# Roadmap',
'',
'### Phase 1: Foundation',
'',
'Plans: 3 plans across 2 waves',
'- [ ] 01-01-PLAN.md — Task A',
'- [ ] 01-02-PLAN.md — Task B',
'- [ ] 01-03-PLAN.md — Task C',
'',
].join('\n');
tmpDir = makePlanProject({
'.planning/ROADMAP.md': roadmap,
'.planning/phases/01-foundation/01-01-PLAN.md': makePlan({ phase: '1', plan: '01-01', wave: 1 }),
'.planning/phases/01-foundation/01-02-PLAN.md': makePlan({ phase: '1', plan: '01-02', wave: 1 }),
'.planning/phases/01-foundation/01-03-PLAN.md': makePlan({ phase: '1', plan: '01-03', wave: 2, dependsOn: ['01-01'] }),
});
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.updated, true,
'Plans-block with "N plans across N waves" inline text must be detected');
});
test('**Plans:** (bold markdown wrapper) is detected as a Plans-block', (_t) => {
// Bold wrapper: `**Plans:** 3 plans across 2 waves`
const roadmap = [
'# Roadmap',
'',
'### Phase 1: Foundation',
'',
'**Plans:** 2 plans',
'- [ ] 01-01-PLAN.md — Task A',
'- [ ] 01-02-PLAN.md — Task B',
'',
].join('\n');
tmpDir = makePlanProject({
'.planning/ROADMAP.md': roadmap,
'.planning/phases/01-foundation/01-01-PLAN.md': makePlan({ phase: '1', plan: '01-01', wave: 1 }),
'.planning/phases/01-foundation/01-02-PLAN.md': makePlan({ phase: '1', plan: '01-02', wave: 2 }),
});
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.updated, true,
'**Plans:** bold-wrapped header must be detected as a Plans-block');
});
test('bare Plans: (no inline text, legacy format) still works after fix', (_t) => {
// Regression guard: the fix must not break the working case
const roadmap = [
'# Roadmap',
'',
'### Phase 1: Foundation',
'',
'Plans:',
'- [ ] 01-01-PLAN.md — Task A',
'- [ ] 01-02-PLAN.md — Task B',
'',
].join('\n');
tmpDir = makePlanProject({
'.planning/ROADMAP.md': roadmap,
'.planning/phases/01-foundation/01-01-PLAN.md': makePlan({ phase: '1', plan: '01-01', wave: 1 }),
'.planning/phases/01-foundation/01-02-PLAN.md': makePlan({ phase: '1', plan: '01-02', wave: 2 }),
});
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.updated, true,
'bare Plans: (legacy format) must still be detected after the fix');
});
});
// ---------------------------------------------------------------------------
// Bug 3 — Plan-ID extraction: decimal phase IDs like 02.3-01
// ---------------------------------------------------------------------------
describe('bug #3691 — Bug 3: decimal plan IDs (e.g. 02.3-01-PLAN.md) parse correctly', () => {
let tmpDir;
afterEach(() => cleanup(tmpDir));
test('decimal plan ID 02.3-01 is captured fully and matched to the correct wave', (_t) => {
// Pre-fix: `[\w-]+?` stops at `.` → captures `02` only → planData.find misses → wave = 1 for all
// Post-fix: `[\w.-]+?` captures `02.3-01` → planData.find resolves → correct wave written
const roadmap = [
'# Roadmap',
'',
'### Phase 02.3: Surgical edit ops',
'',
'Plans: 2 plans across 2 waves',
'- [ ] 02.3-01-PLAN.md — Path resolver',
'- [ ] 02.3-02-PLAN.md — Op handlers',
'',
].join('\n');
tmpDir = makePlanProject({
'.planning/ROADMAP.md': roadmap,
'.planning/phases/02.3-surgical-edit-ops/02.3-01-PLAN.md': makePlan({ phase: '02.3', plan: '02.3-01', wave: 1 }),
'.planning/phases/02.3-surgical-edit-ops/02.3-02-PLAN.md': makePlan({ phase: '02.3', plan: '02.3-02', wave: 2, dependsOn: ['02.3-01'] }),
});
const result = runGsdTools('roadmap annotate-dependencies 02.3', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.updated, true,
'decimal-phase ROADMAP with inline Plans: summary must be annotated');
assert.strictEqual(out.waves, 2,
'two distinct waves must be identified (02.3-01→wave 1, 02.3-02→wave 2)');
const written = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(/Wave 1/.test(written), 'Wave 1 header must appear in output');
assert.ok(/Wave 2/.test(written), 'Wave 2 header must appear in output');
});
test('combined fixture: decimal phase + bold Plans: header (both bugs together)', (_t) => {
// Exercises Bug 1 (bold **Plans:** header) AND Bug 3 (decimal IDs) simultaneously.
// This is the exact ROADMAP fragment from the issue report.
const roadmap = [
'# Roadmap',
'',
'## Milestone v1.2',
'',
'### Phase 02.3: Surgical edit ops',
'',
'**Plans:** 3 plans across 2 waves',
'- [ ] 02.3-01-PLAN.md — Path resolver',
'- [ ] 02.3-02-PLAN.md — Op handlers',
'- [ ] 02.3-03-PLAN.md — Tests',
'',
].join('\n');
tmpDir = makePlanProject({
'.planning/ROADMAP.md': roadmap,
'.planning/phases/02.3-surgical-edit-ops/02.3-01-PLAN.md': makePlan({ phase: '02.3', plan: '02.3-01', wave: 1 }),
'.planning/phases/02.3-surgical-edit-ops/02.3-02-PLAN.md': makePlan({ phase: '02.3', plan: '02.3-02', wave: 1 }),
'.planning/phases/02.3-surgical-edit-ops/02.3-03-PLAN.md': makePlan({ phase: '02.3', plan: '02.3-03', wave: 2, dependsOn: ['02.3-01', '02.3-02'] }),
});
const result = runGsdTools('roadmap annotate-dependencies 02.3', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.updated, true,
'combined fixture (bold Plans: + decimal IDs) must produce updated: true');
assert.strictEqual(out.waves, 2,
'wave 2 dependency must be detected from decimal plan IDs');
});
});
// ---------------------------------------------------------------------------
// F3 review fix — Leading-dot ID validation guard (defensive, malformed ROADMAP)
// ---------------------------------------------------------------------------
describe('review fix F3 — leading-dot plan ID is rejected (defensive guard)', () => {
let tmpDir;
afterEach(() => cleanup(tmpDir));
test('checklist line with leading-dot plan ID is skipped and does not silently default to wave 1', (_t) => {
// Guards: `.invalid-PLAN.md` would be captured as `.invalid` by the `[\w.-]+?` regex
// (since `.` is now included), which starts with a dot — an invalid ID.
// Without the guard, planData.find() misses it and wave defaults to 1, silently
// polluting the output. With the guard, the line is skipped entirely.
// We verify this by having TWO real plans with known waves (1 and 2), plus one
// malformed line. The malformed line must not appear in the wave-annotated output.
const roadmap = [
'# Roadmap',
'',
'### Phase 1: Foundation',
'',
'Plans: 3 items',
'- [ ] 01-01-PLAN.md — Task A',
'- [ ] .invalid-PLAN.md — Corrupted entry',
'- [ ] 01-02-PLAN.md — Task B',
'',
].join('\n');
tmpDir = makePlanProject({
'.planning/ROADMAP.md': roadmap,
'.planning/phases/01-foundation/01-01-PLAN.md': makePlan({ phase: '1', plan: '01-01', wave: 1 }),
'.planning/phases/01-foundation/01-02-PLAN.md': makePlan({ phase: '1', plan: '01-02', wave: 2, dependsOn: ['01-01'] }),
});
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
// Two valid plans resolve to 2 waves — annotation must still proceed
assert.strictEqual(out.updated, true, 'annotation must proceed despite malformed line');
assert.strictEqual(out.waves, 2, 'two distinct waves from the two valid plans');
const written = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
// The malformed line should not appear in the written output (it was skipped)
assert.ok(!written.includes('.invalid-PLAN.md'),
'malformed leading-dot entry must be dropped from the annotated output');
});
});
// ---------------------------------------------------------------------------
// F4 review additions — adversarial test gaps
// ---------------------------------------------------------------------------
describe('review fix F4 — adversarial test gaps', () => {
let tmpDir;
afterEach(() => cleanup(tmpDir));
test('001.10-PLAN.md multi-decimal leading-zero ID is captured fully and wave-assigned correctly', (_t) => {
// Guards regression of Bug 3: `[\w-]+?` would stop at the first `.` and
// capture `001` instead of `001.10`, which never matches any planData entry.
// Post-fix `[\w.-]+?` must capture `001.10` in full.
const roadmap = [
'# Roadmap',
'',
'### Phase 001.10: Extended decimal phase',
'',
'Plans: 2 plans across 2 waves',
'- [ ] 001.10-01-PLAN.md — First task',
'- [ ] 001.10-02-PLAN.md — Second task',
'',
].join('\n');
tmpDir = makePlanProject({
'.planning/ROADMAP.md': roadmap,
'.planning/phases/001.10-extended/001.10-01-PLAN.md': makePlan({ phase: '001.10', plan: '001.10-01', wave: 1 }),
'.planning/phases/001.10-extended/001.10-02-PLAN.md': makePlan({ phase: '001.10', plan: '001.10-02', wave: 2, dependsOn: ['001.10-01'] }),
});
const result = runGsdTools('roadmap annotate-dependencies 001.10', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.updated, true,
'multi-decimal leading-zero plan ID must be captured fully and annotated');
assert.strictEqual(out.waves, 2,
'wave 2 dependency must be resolved from full 001.10-02 ID (not truncated to 001)');
});
test('**Plans:** (bold, no trailing text) is matched and checklist is processed', (_t) => {
// Guards the bare-bold variant: `**Plans:**` with nothing after the colon.
// The `[^\n]*` quantifier accepts zero chars so this should already work,
// but this test would fail if `\*{0,2}Plans\*{0,2}` regressed to require no stars.
const roadmap = [
'# Roadmap',
'',
'### Phase 1: Foundation',
'',
'**Plans:**',
'- [ ] 01-01-PLAN.md — Task A',
'- [ ] 01-02-PLAN.md — Task B',
'',
].join('\n');
tmpDir = makePlanProject({
'.planning/ROADMAP.md': roadmap,
'.planning/phases/01-foundation/01-01-PLAN.md': makePlan({ phase: '1', plan: '01-01', wave: 1 }),
'.planning/phases/01-foundation/01-02-PLAN.md': makePlan({ phase: '1', plan: '01-02', wave: 2, dependsOn: ['01-01'] }),
});
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.updated, true,
'**Plans:** bare-bold variant (no trailing text) must be detected and annotated');
assert.strictEqual(out.waves, 2,
'wave assignment must resolve correctly from planData for bare-bold variant');
});
});
// ---------------------------------------------------------------------------
// Bug #1103 — leading newline dropped in replace, fusing adjacent lines
//
// On the SUCCESSFUL mutation path, the block matcher anchors with `(?:^|\n)`.
// On a mid-string match (a `**Plans:** N plans` bold summary line directly above
// a bare `Plans:` block) `plansBlockMatch[0]` begins with the consumed `\n`. The
// replacement did not re-emit it, fusing the summary line onto the header →
// `**Plans:** 3 plansPlans:`. Distinct from #3691's silent-no-op detection bugs;
// #3691's Bug 1 fix is what let this two-line layout be matched and exposed it.
// ---------------------------------------------------------------------------
describe('bug #1103 — annotate-dependencies preserves newline before Plans: header', () => {
let tmpDir;
afterEach(() => cleanup(tmpDir));
test('bold **Plans:** summary line followed by bare Plans: header are not fused', (_t) => {
const roadmap = [
'# Roadmap',
'',
'### Phase 1: Foundation',
'',
'**Goal:** Set up project',
'',
'**Plans:** 3 plans',
'Plans:',
'- [ ] 01-01-PLAN.md — Task A',
'- [ ] 01-02-PLAN.md — Task B',
'- [ ] 01-03-PLAN.md — Task C',
'',
].join('\n');
tmpDir = makePlanProject({
'.planning/ROADMAP.md': roadmap,
'.planning/phases/01-foundation/01-01-PLAN.md': makePlan({ phase: '1', plan: '01-01', wave: 1 }),
'.planning/phases/01-foundation/01-02-PLAN.md': makePlan({ phase: '1', plan: '01-02', wave: 1 }),
'.planning/phases/01-foundation/01-03-PLAN.md': makePlan({ phase: '1', plan: '01-03', wave: 2, dependsOn: ['01-01'] }),
});
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.updated, true, 'annotation must succeed and write back');
const written = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
// Core assertion: the malformed fusion string must NOT appear.
assert.ok(
!written.includes('plansPlans:'),
`ROADMAP must not contain the fused string "plansPlans:"; got:\n${written}`
);
// The bold summary line must keep its line boundary before Plans:.
assert.ok(
written.includes('**Plans:** 3 plans\nPlans:'),
`**Plans:** 3 plans must be followed by a newline before Plans:; got:\n${written}`
);
assert.ok(written.includes('Wave'), 'wave annotation must appear in ROADMAP.md');
});
test('inline Plans: header (no bold summary prefix) still annotates after fix', (_t) => {
const roadmap = [
'# Roadmap',
'',
'### Phase 1: Foundation',
'',
'Plans: 2 plans',
'- [ ] 01-01-PLAN.md — Task A',
'- [ ] 01-02-PLAN.md — Task B',
'',
].join('\n');
tmpDir = makePlanProject({
'.planning/ROADMAP.md': roadmap,
'.planning/phases/01-foundation/01-01-PLAN.md': makePlan({ phase: '1', plan: '01-01', wave: 1 }),
'.planning/phases/01-foundation/01-02-PLAN.md': makePlan({ phase: '1', plan: '01-02', wave: 2, dependsOn: ['01-01'] }),
});
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.updated, true, 'inline Plans: header must still be annotated after fix');
});
test('mid-string Plans: header keeps exact heading→header spacing (no extra newline)', (_t) => {
// `phaseSection` always begins with the `### Phase` heading, so the `Plans:`
// match is always mid-string and `(?:^|\n)` matches a `\n` (the start-of-string
// `^` branch is unreachable through the handler). This guards the inverse of the
// bug: the re-emitted newline must restore EXACTLY the one `\n` the regex
// consumed — not a doubled blank line and not a fusion.
const roadmap = [
'### Phase 1: Foundation',
'',
'Plans:',
'- [ ] 01-01-PLAN.md — Task A',
'- [ ] 01-02-PLAN.md — Task B',
'',
].join('\n');
tmpDir = makePlanProject({
'.planning/ROADMAP.md': roadmap,
'.planning/phases/01-foundation/01-01-PLAN.md': makePlan({ phase: '1', plan: '01-01', wave: 1 }),
'.planning/phases/01-foundation/01-02-PLAN.md': makePlan({ phase: '1', plan: '01-02', wave: 2, dependsOn: ['01-01'] }),
});
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.updated, true, 'Plans: must still be detected and annotated');
const written = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(
written.includes('### Phase 1: Foundation\n\nPlans:'),
`heading→Plans spacing must be preserved exactly; got:\n${written}`
);
assert.ok(!written.includes('FoundationPlans:'), 'heading must not fuse onto Plans:');
assert.ok(!written.includes('Foundation\n\n\nPlans:'), 'no doubled blank line before Plans:');
});
});

View File

@@ -1,376 +0,0 @@
'use strict';
// allow-test-rule: reads runtime STATE.md written to temp dir — behavioral output test, not source-grep
// Regression tests for bug #397.
//
// STATE.md fields edited by an executor (e.g. a hand-authored Resume File path,
// a custom Status value, or a custom Last Activity entry) were silently overwritten
// by the next call to record-session, advance-plan, or planned-phase because the
// handlers used unconditional stateReplaceField / stateReplaceFieldWithFallback
// calls, even when the option was not passed by the caller.
//
// Fix: introduce KNOWN_TEMPLATE_DEFAULTS (a per-field table of string values that
// are safe to replace because they came from a template) and
// stateReplaceFieldIfTemplate (a helper that only replaces when the current value
// is a template default or absent). Handlers must consult this table rather than
// writing unconditionally.
//
// The 7 baseline cases verified here:
//
// 1. record-session WITHOUT --resume-file when Resume File is executor-authored
// → preserved (must NOT be replaced with 'None')
// 2. record-session WITHOUT --resume-file when Resume File is 'None'
// → remains 'None' (template-default → template-default is fine)
// 3. record-session WITH --resume-file → explicit caller value wins (always)
// 4. advance-plan phase-complete when Status is executor-authored → preserved
// 5. advance-plan phase-complete when Status is a known default → replaced
// 6. advance-plan advance when Last Activity is executor-authored → preserved
// 7. updateCurrentPositionFields with executor-authored Current Position values
// → preserved
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { cleanup } = require('./helpers.cjs');
const ROOT = path.join(__dirname, '..');
const TOOLS_PATH = path.join(ROOT, 'gsd-core', 'bin', 'gsd-tools.cjs');
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeTempPlanning(stateContent) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-397-'));
const planningDir = path.join(dir, '.planning');
fs.mkdirSync(planningDir, { recursive: true });
fs.writeFileSync(path.join(planningDir, 'STATE.md'), stateContent, 'utf8');
return dir;
}
function readState(dir) {
return fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8');
}
function runGsdState(args, cwd) {
const { execFileSync } = require('child_process');
const env = {
...process.env,
GSD_SESSION_KEY: '',
CODEX_THREAD_ID: '',
CLAUDE_SESSION_ID: '',
CLAUDE_CODE_SSE_PORT: '',
OPENCODE_SESSION_ID: '',
};
try {
execFileSync(process.execPath, [TOOLS_PATH, 'state', ...args], {
cwd,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
env,
});
return { success: true };
} catch (err) {
return { success: false, error: err.stderr?.toString().trim() || err.message };
}
}
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
// Case 1: Resume File is executor-authored (not a template default)
const STATE_EXECUTOR_RESUME_FILE = `# GSD State
## Configuration
Current Phase: 2
Total Plans in Phase: 4
Current Plan: 1
Status: Ready to execute
## Current Position
Phase: 2
Plan: 1 of 4
Status: Ready to execute
Last activity: 2026-01-01
## Session Continuity
Last session: 2026-01-01T00:00:00.000Z
Last Date: 2026-01-01T00:00:00.000Z
Resume File: /home/user/my-custom-context.md
Stopped At: Phase 2 Plan 1 complete
`;
// Case 2: Resume File is 'None' (the known template default)
const STATE_DEFAULT_RESUME_FILE = `# GSD State
## Configuration
Current Phase: 2
Total Plans in Phase: 4
Current Plan: 1
Status: Ready to execute
## Current Position
Phase: 2
Plan: 1 of 4
Status: Ready to execute
Last activity: 2026-01-01
## Session Continuity
Last session: 2026-01-01T00:00:00.000Z
Last Date: 2026-01-01T00:00:00.000Z
Resume File: None
Stopped At: Phase 2 Plan 1 complete
`;
// Cases 4 & 7: Status is executor-authored in both Configuration and Current Position.
// Current Plan=2, Total Plans=2 → triggers the phase-complete branch of advance-plan.
const STATE_EXECUTOR_STATUS = `# GSD State
## Configuration
Current Phase: 3
Total Plans in Phase: 2
Current Plan: 2
Status: Awaiting QA sign-off before proceeding
## Current Position
Phase: 3
Plan: 2 of 2
Status: Awaiting QA sign-off before proceeding
Last activity: 2026-01-01
## Session Continuity
Last session: 2026-01-01T00:00:00.000Z
Last Date: 2026-01-01T00:00:00.000Z
Resume File: None
`;
// Case 5: Status IS a known template default ('Ready to execute').
// Current Plan=2, Total Plans=2 → triggers the phase-complete branch.
const STATE_DEFAULT_STATUS = `# GSD State
## Configuration
Current Phase: 3
Total Plans in Phase: 2
Current Plan: 2
Status: Ready to execute
## Current Position
Phase: 3
Plan: 2 of 2
Status: Ready to execute
Last activity: 2026-01-01
## Session Continuity
Last session: 2026-01-01T00:00:00.000Z
Last Date: 2026-01-01T00:00:00.000Z
Resume File: None
`;
// Case 6: The only Last Activity field in the document is executor-authored
// (a narrative, not a bare ISO date). Current Plan=1, Total=3 → advance branch.
const STATE_EXECUTOR_LAST_ACTIVITY = `# GSD State
## Configuration
Current Phase: 2
Total Plans in Phase: 3
Current Plan: 1
Status: Ready to execute
Last Activity: Unblocked after infra fix — merged PR #88 manually
## Current Position
Phase: 2
Plan: 1 of 3
Status: Ready to execute
Last activity: 2026-01-01
## Session Continuity
Last session: 2026-01-01T00:00:00.000Z
Last Date: 2026-01-01T00:00:00.000Z
Resume File: None
`;
// Case 7: Current Position has executor-authored Status and Last activity.
// Current Plan=2, Total=3 → advance branch.
const STATE_EXECUTOR_CURRENT_POSITION = `# GSD State
## Configuration
Current Phase: 4
Total Plans in Phase: 3
Current Plan: 2
Status: Ready to execute
## Current Position
Phase: 4
Plan: 2 of 3
Status: On hold — waiting for upstream dependency merge
Last activity: 2026-02-15 -- blocked by infra; resume after merge
## Session Continuity
Last session: 2026-01-01T00:00:00.000Z
Last Date: 2026-01-01T00:00:00.000Z
Resume File: None
`;
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('bug #397: executor-authored STATE.md fields must be preserved', () => {
// Case 1: record-session without --resume-file, Resume File is executor-authored
test('case 1: record-session without --resume-file preserves executor-authored Resume File', () => {
const dir = makeTempPlanning(STATE_EXECUTOR_RESUME_FILE);
try {
const r = runGsdState(['record-session', '--stopped-at', 'Plan 1 complete'], dir);
assert.ok(r.success, `record-session failed: ${r.error}`);
const after = readState(dir);
const rfMatch = after.match(/Resume File:\s*(.+)/i);
assert.ok(rfMatch, 'Resume File field not found in STATE.md after record-session');
assert.strictEqual(
rfMatch[1].trim(),
'/home/user/my-custom-context.md',
`record-session overwrote executor-authored Resume File with '${rfMatch[1].trim()}'`,
);
} finally {
cleanup(dir);
}
});
// Case 2: record-session without --resume-file, Resume File is 'None' (template default)
test('case 2: record-session without --resume-file keeps "None" when it is already "None"', () => {
const dir = makeTempPlanning(STATE_DEFAULT_RESUME_FILE);
try {
const r = runGsdState(['record-session', '--stopped-at', 'Plan complete'], dir);
assert.ok(r.success, `record-session failed: ${r.error}`);
const after = readState(dir);
const rfMatch = after.match(/Resume File:\s*(.+)/i);
assert.ok(rfMatch, 'Resume File field not found in STATE.md after record-session');
assert.strictEqual(
rfMatch[1].trim(),
'None',
`Expected 'None' to remain when it was already 'None', got: ${rfMatch[1].trim()}`,
);
} finally {
cleanup(dir);
}
});
// Case 3: record-session WITH --resume-file — explicit value always wins
test('case 3: record-session with --resume-file sets the explicit value', () => {
const dir = makeTempPlanning(STATE_EXECUTOR_RESUME_FILE);
try {
const r = runGsdState(['record-session', '--resume-file', '/tmp/new-resume.md'], dir);
assert.ok(r.success, `record-session failed: ${r.error}`);
const after = readState(dir);
const rfMatch = after.match(/Resume File:\s*(.+)/i);
assert.ok(rfMatch, 'Resume File field not found in STATE.md after record-session');
assert.strictEqual(
rfMatch[1].trim(),
'/tmp/new-resume.md',
`Expected explicit --resume-file value to be written, got: ${rfMatch[1].trim()}`,
);
} finally {
cleanup(dir);
}
});
// Case 4: advance-plan phase-complete, Status is executor-authored → preserved
test('case 4: advance-plan (phase-complete) preserves executor-authored Status', () => {
const dir = makeTempPlanning(STATE_EXECUTOR_STATUS);
try {
// Current Plan=2, Total=2 → phase-complete branch
const r = runGsdState(['advance-plan'], dir);
assert.ok(r.success, `advance-plan failed: ${r.error}`);
const after = readState(dir);
// The Configuration-level Status must not be clobbered
const statusMatch = after.match(/^Status:\s*(.+)/m);
assert.ok(statusMatch, 'Status field not found after advance-plan');
assert.strictEqual(
statusMatch[1].trim(),
'Awaiting QA sign-off before proceeding',
`advance-plan overwrote executor-authored Status: got '${statusMatch[1].trim()}'`,
);
} finally {
cleanup(dir);
}
});
// Case 5: advance-plan phase-complete, Status is a known default → replaced
test('case 5: advance-plan (phase-complete) replaces known-default Status with phase-complete value', () => {
const dir = makeTempPlanning(STATE_DEFAULT_STATUS);
try {
// Current Plan=2, Total=2 → phase-complete branch
const r = runGsdState(['advance-plan'], dir);
assert.ok(r.success, `advance-plan failed: ${r.error}`);
const after = readState(dir);
const statusMatch = after.match(/^Status:\s*(.+)/m);
assert.ok(statusMatch, 'Status field not found after advance-plan');
// 'Ready to execute' is a known default and should be replaced
assert.notStrictEqual(
statusMatch[1].trim(),
'Ready to execute',
`Status should have been updated from 'Ready to execute' after phase-complete, but was not`,
);
assert.ok(
statusMatch[1].includes('Phase complete') || statusMatch[1].includes('ready for verification'),
`Expected phase-complete Status text, got: '${statusMatch[1].trim()}'`,
);
} finally {
cleanup(dir);
}
});
// Case 6: advance-plan normal advance, top-level Last Activity is executor-authored → preserved
test('case 6: advance-plan (normal advance) preserves executor-authored Last Activity', () => {
const dir = makeTempPlanning(STATE_EXECUTOR_LAST_ACTIVITY);
try {
// Current Plan=1, Total=3 → advance branch
const r = runGsdState(['advance-plan'], dir);
assert.ok(r.success, `advance-plan failed: ${r.error}`);
const after = readState(dir);
// The top-level Last Activity (in Configuration section) must be preserved
const laMatch = after.match(/^Last Activity:\s*(.+)/im);
assert.ok(laMatch, 'Last Activity field not found after advance-plan');
assert.strictEqual(
laMatch[1].trim(),
'Unblocked after infra fix — merged PR #88 manually',
`advance-plan overwrote executor-authored Last Activity: got '${laMatch[1].trim()}'`,
);
} finally {
cleanup(dir);
}
});
// Case 7: advance-plan preserves executor-authored Status and Last activity in Current Position
test('case 7: advance-plan preserves executor-authored Current Position Status and Last activity', () => {
const dir = makeTempPlanning(STATE_EXECUTOR_CURRENT_POSITION);
try {
// Current Plan=2, Total=3 → advance branch
const r = runGsdState(['advance-plan'], dir);
assert.ok(r.success, `advance-plan failed: ${r.error}`);
const after = readState(dir);
const posMatch = after.match(/##\s*Current Position\s*\n([\s\S]*?)(?=\n##|$)/i);
assert.ok(posMatch, 'Current Position section not found after advance-plan');
const posBody = posMatch[1];
const posStatusMatch = posBody.match(/^Status:\s*(.+)/m);
assert.ok(posStatusMatch, 'Status field not found in Current Position section');
assert.strictEqual(
posStatusMatch[1].trim(),
'On hold — waiting for upstream dependency merge',
`advance-plan overwrote executor-authored Current Position Status: got '${posStatusMatch[1].trim()}'`,
);
const posActivityMatch = posBody.match(/^Last activity:\s*(.+)/im);
assert.ok(posActivityMatch, 'Last activity field not found in Current Position section');
assert.ok(
posActivityMatch[1].includes('blocked by infra'),
`advance-plan overwrote executor-authored Current Position Last activity: got '${posActivityMatch[1].trim()}'`,
);
} finally {
cleanup(dir);
}
});
});

View File

@@ -1,212 +0,0 @@
'use strict';
/**
* Regression tests for issue #416 (open-gsd/gsd-core).
*
* Bug: getActiveMilestoneArchiveDir falls back to the newest archive directory
* when STATE.md names a milestone that has no matching archive yet, producing
* W007 false positives for phases from a prior (completed) milestone.
*
* Fix: when STATE.md is present and parseable and names a milestone, but no
* milestones/<vX.Y>-phases/ directory matches, return null. The version-sort
* fallback to the newest archive fires only when STATE.md is absent or
* unparseable.
*
* Knuth invariant: the resolver answers one question —
* "what archive directory holds the active milestone's phases?"
* Answer space: <dir> | null.
*/
process.env.GSD_TEST_MODE = '1';
const { describe, test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { runGsdTools, cleanup } = require('./helpers.cjs');
// ── helpers ──────────────────────────────────────────────────────────────────
function mkplanning(base) {
const planningDir = path.join(base, '.planning');
const phasesDir = path.join(planningDir, 'phases');
fs.mkdirSync(phasesDir, { recursive: true });
return planningDir;
}
function writeMinimalRoadmap(planningDir, phases) {
// phases: array of { num, name, checked }
const checkboxes = phases.map(({ num, name, checked }) =>
`- [${checked ? 'x' : ' '}] **Phase ${num}:** ${name}`,
).join('\n');
const headings = phases.map(({ num, name }) =>
`### Phase ${num}: ${name}\n**Goal:** Completed.\n`,
).join('\n');
fs.writeFileSync(
path.join(planningDir, 'ROADMAP.md'),
`# Roadmap\n\n${checkboxes}\n\n${headings}`,
);
}
function writeStateMdMilestone(planningDir, milestone) {
fs.writeFileSync(
path.join(planningDir, 'STATE.md'),
`# State\n\n**milestone:** ${milestone}\n**Current Phase:** 23\n**Status:** In progress\n`,
);
}
function writeProjectMd(planningDir) {
fs.writeFileSync(
path.join(planningDir, 'PROJECT.md'),
'# Project\n\n## What This Is\nTest.\n\n## Core Value\nTest.\n\n## Requirements\nTest.\n',
);
}
function writeConfigJson(planningDir) {
fs.writeFileSync(
path.join(planningDir, 'config.json'),
JSON.stringify({ model_profile: 'balanced' }),
);
}
function mkArchivePhases(planningDir, version, phaseNums) {
// Creates .planning/milestones/<version>-phases/<NN>-phase-name/ dirs
const archiveDir = path.join(planningDir, 'milestones', `${version}-phases`);
for (const num of phaseNums) {
const padded = String(num).padStart(2, '0');
fs.mkdirSync(path.join(archiveDir, `${padded}-phase-${num}`), { recursive: true });
}
return archiveDir;
}
// ─────────────────────────────────────────────────────────────────────────────
// Case 1: STATE.md milestone: v6.0, only v5.0-phases/ on disk
// → resolver returns null, verifier emits zero W007 for phases 17–22
// ─────────────────────────────────────────────────────────────────────────────
describe('bug #416 case 1: STATE.md v6.0 with only v5.0-phases/ on disk → null, no W007', () => {
let tmpDir;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-416-c1-'));
const planningDir = mkplanning(tmpDir);
writeProjectMd(planningDir);
writeConfigJson(planningDir);
// Active milestone is v6.0 — no archive for it yet (phases live in flat phases/)
writeStateMdMilestone(planningDir, 'v6.0');
// v5.0 was the prior completed milestone; its archive exists on disk
mkArchivePhases(planningDir, 'v5.0', [17, 18, 19, 20, 21, 22]);
// ROADMAP reflects only v6.0 phases (v5.0 phases are in a prior milestone,
// not in the current roadmap section)
writeMinimalRoadmap(planningDir, [
{ num: 23, name: 'New Foundation', checked: false },
]);
});
after(() => { cleanup(tmpDir); });
test('validate health emits zero W007 warnings (no prior-milestone phases surfaced)', () => {
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
assert.strictEqual(result.success, true, `validate health failed: ${result.error}`);
const data = JSON.parse(result.output);
const w007 = (data.warnings ?? []).filter((w) => w.code === 'W007');
assert.strictEqual(
w007.length,
0,
`Expected zero W007 — phases 17–22 from v5.0-phases/ must not appear as "active".\nGot: ${JSON.stringify(w007, null, 2)}`,
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Case 2: No STATE.md, multiple archives on disk → version-sort fallback
// returns the highest-versioned archive (existing behavior preserved)
// ─────────────────────────────────────────────────────────────────────────────
describe('bug #416 case 2: no STATE.md + multiple archives → version-sort fallback to newest', () => {
let tmpDir;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-416-c2-'));
const planningDir = mkplanning(tmpDir);
writeProjectMd(planningDir);
writeConfigJson(planningDir);
// No STATE.md — resolver must use the version-sort fallback
// Two archives: v4.0 and v5.0; v5.0 is newer
mkArchivePhases(planningDir, 'v4.0', [10, 11, 12]);
mkArchivePhases(planningDir, 'v5.0', [17, 18, 19]);
// ROADMAP lists v5.0 phases so W007 does not fire
writeMinimalRoadmap(planningDir, [
{ num: 17, name: 'Alpha', checked: true },
{ num: 18, name: 'Beta', checked: true },
{ num: 19, name: 'Gamma', checked: true },
]);
});
after(() => { cleanup(tmpDir); });
test('validate health succeeds and does not emit W007 for v5.0 archive phases', () => {
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
assert.strictEqual(result.success, true, `validate health failed: ${result.error}`);
const data = JSON.parse(result.output);
const w007 = (data.warnings ?? []).filter((w) => w.code === 'W007');
// v5.0 phases (17–19) are in the archive returned by the fallback and
// in the ROADMAP, so no W007 should fire.
assert.strictEqual(
w007.length,
0,
`Expected zero W007 for v5.0 archive phases present in ROADMAP.\nGot: ${JSON.stringify(w007, null, 2)}`,
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Case 3: STATE.md milestone: v5.0, matching v5.0-phases/ exists → returns it
// (regression guard — happy path must not break)
// ─────────────────────────────────────────────────────────────────────────────
describe('bug #416 case 3: STATE.md v5.0 with matching v5.0-phases/ → returns archive dir', () => {
let tmpDir;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-416-c3-'));
const planningDir = mkplanning(tmpDir);
writeProjectMd(planningDir);
writeConfigJson(planningDir);
// STATE.md names v5.0 and a matching archive exists
writeStateMdMilestone(planningDir, 'v5.0');
mkArchivePhases(planningDir, 'v5.0', [17, 18, 19, 20, 21, 22]);
// ROADMAP lists v5.0 phases so W007 does not fire
writeMinimalRoadmap(planningDir, [
{ num: 17, name: 'Alpha', checked: true },
{ num: 18, name: 'Beta', checked: true },
{ num: 19, name: 'Gamma', checked: true },
{ num: 20, name: 'Delta', checked: true },
{ num: 21, name: 'Epsilon', checked: true },
{ num: 22, name: 'Zeta', checked: true },
]);
});
after(() => { cleanup(tmpDir); });
test('validate health emits zero W007 — archive phases are in ROADMAP and active', () => {
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
assert.strictEqual(result.success, true, `validate health failed: ${result.error}`);
const data = JSON.parse(result.output);
const w007 = (data.warnings ?? []).filter((w) => w.code === 'W007');
assert.strictEqual(
w007.length,
0,
`Expected zero W007 for matching v5.0 archive with v5.0 in STATE.md.\nGot: ${JSON.stringify(w007, null, 2)}`,
);
});
});

View File

@@ -1,147 +0,0 @@
/**
* Bug #501: extractCurrentMilestone leaks prior-milestone phases when the
* ROADMAP uses a flat shared "## Phase Details" section.
*
* extractCurrentMilestone returns `preamble + currentSection`, where the
* preamble is everything before the first milestone heading (only <details>
* blocks stripped). A flat "## Phase Details" section listing every phase
* across all milestones therefore leaks its `### Phase N:` headings into the
* active-milestone scope, so getMilestonePhaseFilter / buildStateFrontmatter
* count the whole project instead of just the active milestone.
*
* Maintainer direction (triage of #501): fix in code AND make
* `validate consistency` milestone-aware so it does not flag shipped phase
* dirs as orphans once the scope is correctly narrowed.
*
* Layout under test (mirrors the real repro):
* # Roadmap
* ## Phase Details <- flat, BEFORE the first milestone heading
* ### Phase 1..3 <- shipped phases
* ## ✅ v2.0 <- shipped milestone
* ## 🚧 v3.0 (active)
* ### Phase 4..5 <- active-milestone phases
* STATE.md milestone: v3.0 → state json must report total_phases: 2.
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
const ROADMAP = `# Roadmap
Project overview prose that legitimately lives before the milestones.
## Phase Details
### Phase 1: Shipped One
Did a thing.
### Phase 2: Shipped Two
Did another thing.
### Phase 3: Shipped Three
Did a third thing.
## ✅ v2.0: Foundation (shipped)
Summary of the shipped milestone.
## 🚧 v3.0: Active Milestone
### Phase 4: Active One
Doing a thing.
### Phase 5: Active Two
Doing another thing.
`;
const STATE = `---
gsd_state_version: 1.0
milestone: v3.0
milestone_name: Active Milestone
status: in_progress
progress:
total_phases: 2
completed_phases: 0
total_plans: 0
completed_plans: 0
percent: 0
---
# Project State
## Current Position
Phase: 4 (Active One)
`;
describe('flat "## Phase Details" milestone leak (#501)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
const planning = path.join(tmpDir, '.planning');
fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP, 'utf-8');
fs.writeFileSync(path.join(planning, 'STATE.md'), STATE, 'utf-8');
fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8');
// All five phase dirs exist on disk (the flat layout retains shipped dirs).
const phaseDirs = ['01-shipped-one', '02-shipped-two', '03-shipped-three', '04-active-one', '05-active-two'];
for (const d of phaseDirs) {
const dir = path.join(planning, 'phases', d);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, '01-PLAN.md'), '# Plan\n', 'utf-8');
}
});
afterEach(() => {
cleanup(tmpDir);
});
test('state json counts only the active milestone phases, not the flat Phase Details list', () => {
const result = runGsdTools(['state', 'json'], tmpDir);
assert.equal(result.success, true, result.error || result.output);
const state = JSON.parse(result.output);
assert.equal(
state.progress.total_phases,
2,
`active milestone v3.0 has 2 phases (4,5); flat Phase Details (1-3) must not leak. Got total_phases=${state.progress.total_phases}`
);
});
test('validate consistency does not flag shipped phase dirs as not-in-ROADMAP', () => {
// Once milestone scope is correctly narrowed (Test A), the shipped phase
// dirs (1-3) are no longer in the SCOPED roadmap. They are, however, real
// phases listed in the FULL roadmap, so they must NOT be reported as
// "exists on disk but not in ROADMAP" orphans. (#501 — validate must be
// milestone-aware.)
const result = runGsdTools(['validate', 'consistency'], tmpDir);
const payload = JSON.parse(result.output);
const warnings = payload.warnings || [];
const orphanWarnings = warnings.filter((w) => /exists on disk but not in ROADMAP/i.test(w));
assert.deepEqual(
orphanWarnings,
[],
`shipped phase dirs (1-3) are in the full ROADMAP and must not be flagged as orphans. Got: ${JSON.stringify(orphanWarnings)}`
);
});
test('validate health (W007) does not flag shipped phase dirs as not-in-ROADMAP', () => {
// cmdValidateHealth's Check 8 has the same coupling: its W007 membership
// check compared active disk phases against the active-milestone scope.
// Shipped phase dirs in the active phases/ dir must be checked against the
// FULL roadmap so they are not false W007 orphans. (#501)
const result = runGsdTools(['validate', 'health'], tmpDir);
const payload = JSON.parse(result.output);
const warnings = payload.warnings || [];
const w007Orphans = warnings.filter(
(w) => w.code === 'W007' && /exists on disk but not in ROADMAP/i.test(w.message)
);
assert.deepEqual(
w007Orphans,
[],
`shipped phase dirs (1-3) must not produce W007. Got: ${JSON.stringify(w007Orphans.map((w) => w.message))}`
);
});
});

View File

@@ -1,233 +0,0 @@
'use strict';
/**
* Regression test for bug #549:
* STATE.md progress.total_phases is over-counted by 1 when the ROADMAP contains
* a non-phase section heading that happens to match the broader pattern used by
* getMilestonePhaseFilter (e.g. `## Phase Overview:`, `## Phase Details:`).
*
* Root cause:
* buildStateFrontmatter sources total_phases from getMilestonePhaseFilter.phaseCount,
* which uses the looser regex `#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:` to build its
* milestonePhaseNums set. That pattern matches section headings like
* `## Phase Overview:` and `## Phase Details:`, adding non-numeric tokens
* ("Overview", "Details") to the set and inflating phaseCount by 1 per heading.
*
* roadmap.analyze uses the stricter pattern
* `#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)` which requires a
* leading digit, so it only counts real phase headings.
*
* Fix: buildStateFrontmatter (and cmdStateSync) must source total_phases from
* the same digit-anchored phase-heading parser as roadmap.analyze — single
* source of truth.
*
* Scenario under test:
* ROADMAP with 6 integer phases (01-06) + 1 inserted decimal phase (05.1) = 7
* phases, plus a `## Phase Overview:` section header.
*
* BEFORE fix: state json / state sync report total_phases: 8 (7 + 1 spurious
* "Overview" token from the getMilestonePhaseFilter pattern).
* AFTER fix: state json / state sync report total_phases: 7, matching
* roadmap.analyze.phase_count.
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
// ROADMAP: 6 integer phases + 1 inserted decimal + `## Phase Overview:` section header.
// The section header must include a trailing `:` so it matches the getMilestonePhaseFilter
// broader pattern (the bug trigger).
const ROADMAP = `## Milestone v1.0: Test Milestone
## Phase Overview:
High-level narrative about the phases.
### Phase 01: Alpha
**Goal:** alpha
### Phase 02: Beta
**Goal:** beta
### Phase 03: Gamma
**Goal:** gamma
### Phase 04: Delta
**Goal:** delta
### Phase 05: Epsilon
**Goal:** epsilon
### Phase 05.1: Inserted Hotfix (INSERTED)
**Goal:** inserted hotfix
### Phase 06: Zeta
**Goal:** zeta
`;
describe('bug #549 — total_phases over-counted by non-phase section headings', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject('bug-549-');
const planning = path.join(tmpDir, '.planning');
fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP, 'utf-8');
fs.writeFileSync(
path.join(planning, 'STATE.md'),
[
'---',
'gsd_state_version: 1.0',
'milestone: v1.0',
'milestone_name: Test Milestone',
'status: executing',
'---',
'',
'# Project State',
'',
'## Configuration',
'Current Phase: 1',
'Status: Executing Phase 1',
'Last Activity: 2026-01-01',
'',
].join('\n'),
'utf-8',
);
fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8');
// Create 7 phase dirs (6 integer + 1 decimal).
const phaseDirs = [
'01-alpha',
'02-beta',
'03-gamma',
'04-delta',
'05-epsilon',
'05.1-inserted-hotfix',
'06-zeta',
];
for (const d of phaseDirs) {
const dir = path.join(planning, 'phases', d);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'PLAN.md'), '# Plan\n', 'utf-8');
}
});
afterEach(() => {
cleanup(tmpDir);
});
test('state json total_phases matches roadmap.analyze phase_count (7, not 8)', () => {
// Authoritative count from roadmap.analyze — uses the digit-anchored pattern.
const analyzeResult = runGsdTools(['roadmap', 'analyze'], tmpDir);
assert.ok(analyzeResult.success, `roadmap analyze failed: ${analyzeResult.error}`);
const analyzed = JSON.parse(analyzeResult.output);
assert.equal(
analyzed.phase_count,
7,
`roadmap.analyze should count 7 phases (01-06 + 05.1), got ${analyzed.phase_count}`,
);
// State frontmatter must equal the authoritative count.
const stateResult = runGsdTools(['state', 'json'], tmpDir);
assert.ok(stateResult.success, `state json failed: ${stateResult.error}`);
const state = JSON.parse(stateResult.output);
assert.ok(state.progress, 'state json must return a progress block');
assert.equal(
state.progress.total_phases,
7,
`progress.total_phases must be 7 (not 8) — ## Phase Overview: section must not be counted as a phase. Got ${state.progress.total_phases}`,
);
assert.equal(
state.progress.total_phases,
analyzed.phase_count,
`progress.total_phases (${state.progress.total_phases}) must equal roadmap.analyze.phase_count (${analyzed.phase_count})`,
);
});
test('state sync total_phases matches roadmap.analyze phase_count', () => {
// Add a Progress field to the body so cmdStateSync has something to update.
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const before = fs.readFileSync(statePath, 'utf-8');
fs.writeFileSync(statePath, before.replace('Last Activity: 2026-01-01', 'Last Activity: 2026-01-01\nProgress: [░░░░░░░░░░] 0%'), 'utf-8');
const syncResult = runGsdTools(['state', 'sync'], tmpDir);
assert.ok(syncResult.success, `state sync failed: ${syncResult.error}`);
// Read frontmatter via state json (authoritative JSON path).
const stateResult = runGsdTools(['state', 'json'], tmpDir);
assert.ok(stateResult.success, `state json after sync failed: ${stateResult.error}`);
const state = JSON.parse(stateResult.output);
assert.ok(state.progress, 'state json must return a progress block after sync');
assert.equal(
state.progress.total_phases,
7,
`state sync must write total_phases: 7, not 8. ## Phase Overview: must not inflate the count. Got ${state.progress.total_phases}`,
);
});
test('integer-only project without decimal phase also counts correctly', () => {
// Regression guard: the fix must not break projects with no decimal phases.
const tmpDir2 = createTempProject('bug-549-integer-');
try {
const planning2 = path.join(tmpDir2, '.planning');
// ROADMAP: 4 integer phases only + non-phase section heading.
fs.writeFileSync(
path.join(planning2, 'ROADMAP.md'),
[
'## Milestone v1.0: Simple',
'',
'## Phase Overview:',
'',
'### Phase 01: One',
'**Goal:** one',
'',
'### Phase 02: Two',
'**Goal:** two',
'',
'### Phase 03: Three',
'**Goal:** three',
'',
'### Phase 04: Four',
'**Goal:** four',
].join('\n'),
'utf-8',
);
fs.writeFileSync(
path.join(planning2, 'STATE.md'),
'---\ngsd_state_version: 1.0\nmilestone: v1.0\nstatus: executing\n---\n\n# State\n\nStatus: Executing Phase 1\nLast Activity: 2026-01-01\n',
'utf-8',
);
fs.writeFileSync(path.join(planning2, 'config.json'), '{}', 'utf-8');
for (const d of ['01-one', '02-two', '03-three', '04-four']) {
const dir = path.join(planning2, '.planning', 'phases', d);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'PLAN.md'), '# Plan\n', 'utf-8');
}
const analyzeResult = runGsdTools(['roadmap', 'analyze'], tmpDir2);
assert.ok(analyzeResult.success, `roadmap analyze failed: ${analyzeResult.error}`);
const analyzed = JSON.parse(analyzeResult.output);
assert.equal(analyzed.phase_count, 4, `expected 4 phases, got ${analyzed.phase_count}`);
const stateResult = runGsdTools(['state', 'json'], tmpDir2);
assert.ok(stateResult.success, `state json failed: ${stateResult.error}`);
const state = JSON.parse(stateResult.output);
assert.ok(state.progress, 'state json must return a progress block');
assert.equal(
state.progress.total_phases,
4,
`integer-only project: total_phases must be 4, not 5. Got ${state.progress.total_phases}`,
);
} finally {
cleanup(tmpDir2);
}
});
});

View File

@@ -1,248 +0,0 @@
/**
* Bug #557: Active milestone wrapped in <details open> with version only in
* <summary> tag + 🔄 emoji causes extractCurrentMilestone() to fall through
* to stripShippedMilestones(), erasing the active block and making
* roadmap.analyze return phase_count: 0 — which then triggers a premature
* milestone_complete STATE write.
*
* Root cause (two miss paths in extractCurrentMilestone, core.cjs):
* 1. sectionPattern only matches ##/### headings; version in <summary> not found.
* 2. activeMarkerPattern does not include 🔄; only 🚧 is recognised.
* Both misses → stripShippedMilestones() deletes the active <details open> block.
*
* This test will FAIL before the fix (phase_count returns 0) and PASS after.
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
// ─── Fixtures ────────────────────────────────────────────────────────────────
// ROADMAP where the active milestone's version ("v1.3") appears ONLY inside
// a <summary> tag, and the in-progress marker is 🔄 (not 🚧).
// Shipped milestone v1.2 is correctly collapsed in a <details> block.
const ROADMAP_DETAILS_SUMMARY = `# Roadmap
<details>
<summary>✅ v1.2: Foundation (shipped)</summary>
### Phase 1: Bootstrap
**Goal:** Set up infrastructure
### Phase 2: Core API
**Goal:** Build REST API
</details>
<details open>
<summary>🔄 v1.3: Active Sprint</summary>
### Phase 3: Auth
**Goal:** Add authentication
### Phase 4: Dashboard
**Goal:** Build dashboard UI
</details>
`;
// STATE.md with milestone: v1.3 — version matches the <summary> tag above
const STATE_V13 = `---
gsd_state_version: 1.0
milestone: v1.3
milestone_name: Active Sprint
status: in_progress
progress:
total_phases: 2
completed_phases: 0
total_plans: 0
completed_plans: 0
percent: 0
---
# Project State
## Current Position
Phase: 3 (Auth)
`;
// Second variant: active milestone uses the 🔄 emoji in a heading (not just
// <summary>) to confirm the activeMarkerPattern gap is also covered.
const ROADMAP_ROTATE_HEADING = `# Roadmap
<details>
<summary>✅ v2.0: Shipped (shipped)</summary>
### Phase 1: Old Phase
**Goal:** Done
</details>
## 🔄 v2.1: Active Milestone
### Phase 2: New Feature
**Goal:** Build the new feature
### Phase 3: Integration
**Goal:** Wire it all together
`;
const STATE_V21 = `---
gsd_state_version: 1.0
milestone: v2.1
milestone_name: Active Milestone
status: in_progress
---
# Project State
## Current Position
Phase: 2 (New Feature)
`;
// ─── Test suite ───────────────────────────────────────────────────────────────
describe('bug #557 — <details>/<summary> active milestone strip', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
// ── Core repro: version only in <summary> tag ─────────────────────────────
test('roadmap.analyze returns correct phase_count when active milestone uses <summary> + 🔄', () => {
const planning = path.join(tmpDir, '.planning');
fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP_DETAILS_SUMMARY, 'utf-8');
fs.writeFileSync(path.join(planning, 'STATE.md'), STATE_V13, 'utf-8');
fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8');
const result = runGsdTools(['roadmap', 'analyze'], tmpDir);
assert.ok(result.success, `roadmap.analyze failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
output.phase_count >= 2,
`Expected phase_count >= 2 (phases 3 and 4 of v1.3); got phase_count=${output.phase_count}. ` +
`Bug: extractCurrentMilestone() stripped the active <details open> block because ` +
`the version "v1.3" only appears in a <summary> tag and the emoji is 🔄, not 🚧.`
);
});
test('roadmap.analyze does NOT return phase_count: 0 when active milestone is in <details open>', () => {
const planning = path.join(tmpDir, '.planning');
fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP_DETAILS_SUMMARY, 'utf-8');
fs.writeFileSync(path.join(planning, 'STATE.md'), STATE_V13, 'utf-8');
fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8');
const result = runGsdTools(['roadmap', 'analyze'], tmpDir);
assert.ok(result.success, `roadmap.analyze failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.notStrictEqual(
output.phase_count,
0,
'phase_count must not be 0 — a zero count caused by stripping the active block ' +
'is the direct trigger for the premature milestone_complete write.'
);
});
test('roadmap get-phase returns found:true for phase in active <details open> block', () => {
const planning = path.join(tmpDir, '.planning');
fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP_DETAILS_SUMMARY, 'utf-8');
fs.writeFileSync(path.join(planning, 'STATE.md'), STATE_V13, 'utf-8');
fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8');
const result = runGsdTools(['roadmap', 'get-phase', '3'], tmpDir);
assert.ok(result.success, `roadmap get-phase failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(
output.found,
true,
`Phase 3 must be found in the active v1.3 milestone block. ` +
`Bug: stripShippedMilestones() erased the <details open> block so the phase section was lost.`
);
});
test('shipped phases in collapsed <details> are NOT visible to roadmap.analyze (strip preserved for non-active)', () => {
const planning = path.join(tmpDir, '.planning');
fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP_DETAILS_SUMMARY, 'utf-8');
fs.writeFileSync(path.join(planning, 'STATE.md'), STATE_V13, 'utf-8');
fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8');
const result = runGsdTools(['roadmap', 'analyze'], tmpDir);
assert.ok(result.success, `roadmap.analyze failed: ${result.error}`);
const output = JSON.parse(result.output);
const phaseNums = (output.phases || []).map(p => p.number);
assert.ok(
!phaseNums.includes('1') && !phaseNums.includes('2'),
`Shipped phases 1 and 2 (from collapsed <details>) must not appear in the analyze output. ` +
`Got phases: ${JSON.stringify(phaseNums)}`
);
});
// ── 🔄 in heading (not <summary>) also recognised ────────────────────────
test('extractCurrentMilestone recognises 🔄 in milestone heading as in-progress marker', () => {
const planning = path.join(tmpDir, '.planning');
fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP_ROTATE_HEADING, 'utf-8');
fs.writeFileSync(path.join(planning, 'STATE.md'), STATE_V21, 'utf-8');
fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8');
const result = runGsdTools(['roadmap', 'analyze'], tmpDir);
assert.ok(result.success, `roadmap.analyze failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
output.phase_count >= 2,
`Expected phase_count >= 2 for v2.1 with 🔄 heading; got ${output.phase_count}. ` +
`activeMarkerPattern must include 🔄, not just 🚧.`
);
});
// ── Health check W021: milestone_complete vs unstarted phases ─────────────
test('validate health emits W021 when STATE says milestone complete but ROADMAP has unstarted phases', () => {
const planning = path.join(tmpDir, '.planning');
// ROADMAP still has active phases in it
fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP_DETAILS_SUMMARY, 'utf-8');
// STATE falsely says milestone complete
fs.writeFileSync(path.join(planning, 'STATE.md'), `---
gsd_state_version: 1.0
milestone: v1.3
milestone_name: Active Sprint
status: v1.3 milestone complete
---
# Project State
## Current Position
Phase: Milestone v1.3 complete
`, 'utf-8');
fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8');
const result = runGsdTools(['validate', 'health'], tmpDir);
assert.ok(result.success, `validate health failed: ${result.error}`);
const output = JSON.parse(result.output);
const warnings = output.warnings || [];
const w021 = warnings.find(w => w.code === 'W021');
assert.ok(
w021 !== undefined,
`Expected W021 warning (milestone-status vs. roadmap-progress incoherence). ` +
`Got warnings: ${JSON.stringify(warnings.map(w => w.code))}`
);
});
});

View File

@@ -1,229 +0,0 @@
'use strict';
/**
* Regression guard — bug(#853): /gsd-manager and /gsd-autonomous --interactive
* silently skipped worktree isolation + independent verification because they
* dispatched Plan/Execute via Agent(run_in_background=true). On Claude Code a
* backgrounded agent has no Agent/Task tool, so it cannot spawn the nested
* subagents (worktree executors, plan-checker, verifier). The workflows must
* now resolve dispatch capability from the registry (#1708) and run inline
* everywhere except runtimes where dispatch.background && dispatch.backgroundDispatch
* are both true (currently: codex, cursor).
*
* Phase B (#1708): the prose `RUNTIME === 'codex'` rule is graduated to a typed
* `gsd_run query dispatch-should-flatten` query backed by shouldFlattenDispatch()
* from host-integration.cjs and the documentation-sourced capability registry.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createTempProject, cleanup: cleanupDir, runGsdTools } = require('./helpers.cjs');
const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows');
// allow-test-rule: source-text-is-the-product (see #1708)
const MANAGER = fs.readFileSync(path.join(WORKFLOWS_DIR, 'manager.md'), 'utf8');
// allow-test-rule: source-text-is-the-product (see #1708)
const AUTONOMOUS = fs.readFileSync(path.join(WORKFLOWS_DIR, 'autonomous.md'), 'utf8');
describe('bug-853 — manager/autonomous gate background dispatch by runtime', () => {
test('manager.md resolves dispatch-should-flatten before dispatching plan/execute', () => {
// Two dispatch sites (plan + execute), each must use dispatch-should-flatten.
// allow-test-rule: source-text-is-the-product (see #1708)
const matches = MANAGER.match(/dispatch-should-flatten/g) || [];
assert.ok(matches.length >= 2, 'manager.md must use dispatch-should-flatten for both plan and execute dispatch');
});
test('manager.md documents why most runtimes cannot background-dispatch', () => {
// Accept both old singular form (backgrounded agent has no) and new plural form (backgrounded agents have no)
// allow-test-rule: source-text-is-the-product (see #1708)
assert.match(MANAGER, /backgrounded agents? ha(?:s|ve) no `Agent`\/`Task` tool/);
});
test('manager.md gates background dispatch on FLATTEN=false and runs plan/execute inline otherwise', () => {
// Background path uses FLATTEN is false
// allow-test-rule: source-text-is-the-product (see #1708)
assert.match(MANAGER, /If `FLATTEN` is `false`[\s\S]{0,400}?run_in_background=true/);
// Inline is the default/else branch for plan — anchored on FLATTEN=true language (not runtime name)
assert.match(
MANAGER,
/Otherwise[\s\S]{0,100}?`FLATTEN`[\s\S]{0,400}?Skill\(skill="gsd-plan-phase"/,
);
// Inline is the default/else branch for execute — anchored on FLATTEN=true language (not runtime name)
assert.match(
MANAGER,
/Otherwise[\s\S]{0,100}?`FLATTEN`[\s\S]{0,400}?Skill\(skill="gsd-execute-phase"/,
);
});
test('manager.md compound action preamble uses FLATTEN language (not hardcoded runtime names)', () => {
// allow-test-rule: source-text-is-the-product (see #1708)
const compoundActionSection = MANAGER.match(
/### Compound Action \(background \+ inline\)[\s\S]*?Inline verification:/,
);
assert.ok(compoundActionSection, 'manager.md must document compound action runtime dispatch');
// Must gate on FLATTEN being false (not runtime name)
assert.match(
compoundActionSection[0],
/If `FLATTEN` is `false`[\s\S]{0,400}?Spawn all background agents first[\s\S]{0,300}?plan\/execute/,
);
// Otherwise / inline branch must reference FLATTEN being true
assert.match(
compoundActionSection[0],
/Otherwise[\s\S]{0,260}?`FLATTEN`[\s\S]{0,260}?`true`[\s\S]{0,260}?inline/,
);
// Must NOT still hardcode "On Codex:" in this section
assert.doesNotMatch(
compoundActionSection[0],
/\*\*On Codex:\*\*/,
);
// Must NOT still hardcode "On Claude Code or any other non-Codex runtime:"
assert.doesNotMatch(
compoundActionSection[0],
/On Claude Code or any other non-Codex runtime:/,
);
});
test('autonomous.md gates interactive background dispatch using dispatch-should-flatten', () => {
// Two dispatch sites (3b plan + 3c execute), each must use dispatch-should-flatten.
// allow-test-rule: source-text-is-the-product (see #1708)
const autoFlattenMatches = AUTONOMOUS.match(/dispatch-should-flatten/g) || [];
assert.ok(autoFlattenMatches.length >= 2, 'autonomous.md must use dispatch-should-flatten in both 3b (plan) and 3c (execute) interactive branches');
// Accept both old singular form (backgrounded agent has no) and new plural form (backgrounded agents have no)
assert.match(AUTONOMOUS, /backgrounded agents? ha(?:s|ve) no `Agent`\/`Task` tool/);
});
test('autonomous.md gates interactive background dispatch on FLATTEN=false; runs plan/execute inline otherwise', () => {
// Background block: run_in_background=true appears within the FLATTEN=false branch and gsd-plan-phase is nearby
// allow-test-rule: source-text-is-the-product (see #1708)
assert.match(AUTONOMOUS, /If `FLATTEN` is `false`[\s\S]{0,1200}?run_in_background=true[\s\S]{0,600}?gsd-plan-phase/);
// Background block: run_in_background=true appears within the FLATTEN=false branch and gsd-execute-phase is nearby
assert.match(AUTONOMOUS, /If `FLATTEN` is `false`[\s\S]{0,3000}?run_in_background=true[\s\S]{0,200}?gsd-execute-phase/);
// Inline is the otherwise/else branch for plan — anchored on FLATTEN=true language (not runtime name)
assert.match(
AUTONOMOUS,
/Otherwise[\s\S]{0,100}?`FLATTEN`[\s\S]{0,400}?Skill\(skill="gsd-plan-phase"/,
);
// Inline is the otherwise/else branch for execute — anchored on FLATTEN=true language (not runtime name)
assert.match(
AUTONOMOUS,
/Otherwise[\s\S]{0,100}?`FLATTEN`[\s\S]{0,400}?Skill\(skill="gsd-execute-phase"/,
);
});
});
describe('dispatch-should-flatten query — behavioral', () => {
// #853 / #1708: The typed query replaces prose-level RUNTIME===codex checks.
// shouldFlattenDispatch returns false only when both dispatch.background AND
// dispatch.backgroundDispatch are true in the capability registry.
//
// Registry values (from host-integration-capability-matrix.md):
// codex: background=true, backgroundDispatch=true → shouldFlatten=false (may background)
// claude: background=true, backgroundDispatch=false → shouldFlatten=true (must inline)
// cursor: background=true, backgroundDispatch=true → shouldFlatten=false (may background)
// unknown: no entry → fail-closed → shouldFlatten=true (must inline)
test('runtime=codex → shouldFlatten=false (background dispatch safe)', () => {
const tmpDir = createTempProject();
try {
const result = runGsdTools(['query', 'dispatch-should-flatten', '--raw'], tmpDir, {
GSD_RUNTIME: 'codex',
});
assert.ok(result.success, `Expected success, got error: ${result.error}`);
assert.strictEqual(result.output, 'false', `codex should return false (may background), got: ${result.output}`);
} finally {
cleanupDir(tmpDir);
}
});
test('runtime=claude → shouldFlatten=true (must inline)', () => {
const tmpDir = createTempProject();
try {
const result = runGsdTools(['query', 'dispatch-should-flatten', '--raw'], tmpDir, {
GSD_RUNTIME: 'claude',
});
assert.ok(result.success, `Expected success, got error: ${result.error}`);
assert.strictEqual(result.output, 'true', `claude should return true (must inline), got: ${result.output}`);
} finally {
cleanupDir(tmpDir);
}
});
test('runtime=cursor → shouldFlatten=false (background dispatch safe)', () => {
const tmpDir = createTempProject();
try {
const result = runGsdTools(['query', 'dispatch-should-flatten', '--raw'], tmpDir, {
GSD_RUNTIME: 'cursor',
});
assert.ok(result.success, `Expected success, got error: ${result.error}`);
assert.strictEqual(result.output, 'false', `cursor should return false (may background), got: ${result.output}`);
} finally {
cleanupDir(tmpDir);
}
});
test('unknown runtime → shouldFlatten=true (fail-closed → must inline)', () => {
// An unknown runtime has no registry entry → dispatch is null → fail-closed to true.
const tmpDir = createTempProject();
try {
const result = runGsdTools(['query', 'dispatch-should-flatten', '--raw'], tmpDir, {
GSD_RUNTIME: 'unknown-runtime-xyz',
});
// The query must succeed (exit 0) even for unknown runtimes — fail-closed not crash-closed.
assert.ok(result.success, `Expected success (fail-closed), got error: ${result.error}`);
assert.strictEqual(result.output, 'true', `unknown runtime should return true (fail-closed), got: ${result.output}`);
} finally {
cleanupDir(tmpDir);
}
});
test('--json flag returns structured { runtime, shouldFlatten, dispatch }', () => {
const tmpDir = createTempProject();
try {
const result = runGsdTools(['query', 'dispatch-should-flatten', '--json'], tmpDir, {
GSD_RUNTIME: 'codex',
});
assert.ok(result.success, `Expected success, got error: ${result.error}`);
let parsed;
try {
parsed = JSON.parse(result.output);
} catch {
assert.fail(`Expected valid JSON output, got: ${result.output}`);
}
assert.strictEqual(parsed.runtime, 'codex');
assert.strictEqual(parsed.shouldFlatten, false);
assert.ok(parsed.dispatch !== null && typeof parsed.dispatch === 'object', 'dispatch should be an object');
assert.strictEqual(parsed.dispatch.backgroundDispatch, true);
} finally {
cleanupDir(tmpDir);
}
});
test('config.runtime takes precedence when GSD_RUNTIME not set', () => {
// GSD_RUNTIME > config.runtime > 'claude'
// Write config.json with runtime=codex; no GSD_RUNTIME override.
const tmpDir = createTempProject();
try {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ runtime: 'codex' }),
'utf-8',
);
// Override GSD_RUNTIME to '' (empty string) so any ambient value is cleared.
// resolveRuntimeNameFromCandidates treats empty string as absent (normalizes
// to '' which is falsy → skipped → falls through to config.runtime=codex).
// This is the only way to suppress an ambient GSD_RUNTIME since runGsdTools
// merges { ...process.env, ...TEST_ENV_BASE, ...env } — passing '' as the
// override overwrites the ambient value at the correct merge position.
const result = runGsdTools(['query', 'dispatch-should-flatten', '--raw'], tmpDir, {
GSD_RUNTIME: '',
});
// config.runtime=codex with GSD_RUNTIME cleared → codex backgrounds → shouldFlatten=false
assert.ok(result.success, `Expected success, got error: ${result.error}`);
assert.strictEqual(result.output, 'false', `config.runtime=codex (GSD_RUNTIME cleared) should return false (may background), got: ${result.output}`);
} finally {
cleanupDir(tmpDir);
}
});
});

View File

@@ -1,140 +0,0 @@
'use strict';
/**
* Regression test for bug #866: profile-pipeline temp output dirs must be
* created under GSD_TEMP_DIR (path.join(os.tmpdir(), 'gsd')), not directly
* under os.tmpdir() root where reapStaleTempFiles() never scans.
*
* Hardening (adversarial-review follow-up):
* - TMPDIR/TEMP/TMP are redirected to a fixture-scoped directory so the child
* process's os.tmpdir() returns an isolated root. This prevents the test from
* touching the real shared temp root and keeps it out of the production
* reaper's view.
* - Both sides of the startsWith assertion are realpath-normalized to kill the
* macOS /var ↔ /private/var symlink flakiness.
* - An explicit exitCode === 0 assertion is added before JSON.parse so a
* non-zero early-exit produces a clear failure rather than a confusing parse
* error.
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempDir, cleanup } = require('./helpers.cjs');
describe('bug-866: profile-pipeline temp dirs under GSD_TEMP_DIR', () => {
let tmpDir;
let isolatedSysTmp;
beforeEach(() => {
tmpDir = createTempDir('gsd-866-');
// Create an isolated os.tmpdir() root inside the fixture so the child
// process never writes to the real shared temp dir.
isolatedSysTmp = path.join(tmpDir, 'systmp');
fs.mkdirSync(isolatedSysTmp, { recursive: true });
});
afterEach(() => {
cleanup(tmpDir);
});
// Helper: create a minimal synthetic sessions directory structure
function createSessions(root) {
const sessionsDir = path.join(root, 'projects');
const projectDir = path.join(sessionsDir, 'test-project-866');
fs.mkdirSync(projectDir, { recursive: true });
const messages = [
{ type: 'user', userType: 'external', message: { content: 'fix the login bug' }, timestamp: Date.now() },
{ type: 'assistant', message: { content: 'Sure.' }, timestamp: Date.now() },
];
fs.writeFileSync(
path.join(projectDir, 'session-001.jsonl'),
messages.map(m => JSON.stringify(m)).join('\n')
);
return sessionsDir;
}
test('extract-messages output_file is under GSD_TEMP_DIR, not os.tmpdir() root', () => {
const sessionsDir = createSessions(tmpDir);
// Pass the isolated tmp root so the child's os.tmpdir() = isolatedSysTmp.
// Belt-and-suspenders: set all three env vars Node checks (TMPDIR=POSIX,
// TEMP+TMP=Windows).
const result = runGsdTools(
['extract-messages', 'test-project-866', '--path', sessionsDir, '--raw'],
tmpDir,
{ TMPDIR: isolatedSysTmp, TEMP: isolatedSysTmp, TMP: isolatedSysTmp }
);
// Explicit exitCode check first — parse errors are confusing on non-zero exit.
assert.strictEqual(result.exitCode, 0, `extract-messages must exit 0; error: ${result.error}`);
assert.ok(result.success, `extract-messages failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.ok(out.output_file, 'should have output_file in result');
const outputFile = out.output_file;
// The expected GSD_TEMP_DIR from the child's perspective: isolatedSysTmp/gsd
const expectedGsdTempDir = path.join(isolatedSysTmp, 'gsd');
// Normalize both sides via realpath to kill macOS /var↔/private/var symlink
// flakiness. Realpath the existing output_file's parent directory (the file
// itself may have been cleaned up already, but the dir will exist).
const expectedRoot = fs.realpathSync(expectedGsdTempDir);
const outputDir = path.dirname(outputFile);
// The output dir must exist since the tool just wrote there; realpath it.
const actualDir = fs.realpathSync(outputDir);
assert.ok(
actualDir.startsWith(expectedRoot + path.sep) || actualDir === expectedRoot,
`output_file "${outputFile}" must be under GSD_TEMP_DIR "${expectedGsdTempDir}" (realpath: ${expectedRoot}); got dir "${actualDir}"`
);
// Must NOT be directly under the isolated systmp root (i.e., no gsd-pipeline-*
// at depth 1 of isolatedSysTmp).
const rel = path.relative(isolatedSysTmp, outputFile);
const depth1Dir = rel.split(path.sep)[0];
assert.ok(
!depth1Dir.startsWith('gsd-pipeline-'),
`output_file must not be in isolatedSysTmp/gsd-pipeline-* but got depth-1 dir: "${depth1Dir}"`
);
});
test('profile-sample output_file is under GSD_TEMP_DIR, not os.tmpdir() root', () => {
const sessionsDir = createSessions(tmpDir);
const result = runGsdTools(
['profile-sample', '--path', sessionsDir, '--raw'],
tmpDir,
{ TMPDIR: isolatedSysTmp, TEMP: isolatedSysTmp, TMP: isolatedSysTmp }
);
// Explicit exitCode check first.
assert.strictEqual(result.exitCode, 0, `profile-sample must exit 0; error: ${result.error}`);
assert.ok(result.success, `profile-sample failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.ok(out.output_file, 'should have output_file in result');
const outputFile = out.output_file;
const expectedGsdTempDir = path.join(isolatedSysTmp, 'gsd');
const expectedRoot = fs.realpathSync(expectedGsdTempDir);
const outputDir = path.dirname(outputFile);
const actualDir = fs.realpathSync(outputDir);
assert.ok(
actualDir.startsWith(expectedRoot + path.sep) || actualDir === expectedRoot,
`output_file "${outputFile}" must be under GSD_TEMP_DIR "${expectedGsdTempDir}" (realpath: ${expectedRoot}); got dir "${actualDir}"`
);
const rel = path.relative(isolatedSysTmp, outputFile);
const depth1Dir = rel.split(path.sep)[0];
assert.ok(
!depth1Dir.startsWith('gsd-profile-'),
`output_file must not be in isolatedSysTmp/gsd-profile-* but got depth-1 dir: "${depth1Dir}"`
);
});
});

View File

@@ -1,617 +0,0 @@
'use strict';
/**
* Regression guard for bug #905.
*
* `syncStateFrontmatter` (src/state.cts) only preserved `status` from existing
* frontmatter when the body-derived value was missing/unknown. The scalars
* `current_phase`, `current_phase_name`, `current_plan`, and `progress` were
* silently stripped whenever `buildStateFrontmatter` could not extract them from
* the body text — e.g. when an agent removed the bold `**Current Phase:**`
* annotations.
*
* Fix: mirror the `cmdStateJson` fallback pattern in `syncStateFrontmatter` so
* that all four scalars survive a `writeStateMd` / `state sync` call when the
* body no longer carries the annotation but the existing frontmatter does.
*/
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { runGsdTools, createTempProject, createTempDir, cleanup, parseFrontmatter } = require('./helpers.cjs');
// ─────────────────────────────────────────────────────────────────────────────
// Fixture builders
// ─────────────────────────────────────────────────────────────────────────────
/**
* A STATE.md whose YAML frontmatter holds all four scalars but whose body
* does NOT contain the bold `**Current Phase:**` / `**Current Plan:**`
* annotations that `buildStateFrontmatter` uses to re-derive them.
*
* This is the exact scenario that triggered the bug: the body has already lost
* the annotations (e.g. because a CLI tool or agent overwrote it), but the
* frontmatter still holds the ground-truth values. A subsequent `state sync`
* (or any `writeStateMd` call) must not strip them.
*/
function buildStateMdWithoutBodyAnnotations(opts) {
const {
currentPhase = 3,
currentPhaseName = 'Implementation',
currentPlan = 2,
progressPercent = 42,
} = opts || {};
return [
'---',
'gsd_state_version: 1.0',
`current_phase: ${currentPhase}`,
`current_phase_name: ${currentPhaseName}`,
`current_plan: ${currentPlan}`,
'status: executing',
'progress:',
` total_phases: 5`,
` completed_phases: 2`,
` total_plans: 10`,
` completed_plans: 4`,
` percent: ${progressPercent}`,
'---',
'',
'# GSD State',
'',
'## Configuration',
// Intentionally omitting "Current Phase:", "Current Phase Name:",
// "Current Plan:" body annotations to reproduce the bug scenario.
'Status: Executing',
'Last Activity: 2026-01-01',
'',
'## Accumulated Context',
'',
'### Decisions',
'',
'- Use Node 22',
'',
].join('\n');
}
// ─────────────────────────────────────────────────────────────────────────────
// Tests
// ─────────────────────────────────────────────────────────────────────────────
describe('#905: syncStateFrontmatter preserves scalars when body annotations are absent', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('state sync preserves current_phase from existing frontmatter when body lacks annotation', () => {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, buildStateMdWithoutBodyAnnotations({ currentPhase: 3 }));
const syncResult = runGsdTools('state sync', tmpDir);
assert.ok(syncResult.success, `state sync failed: ${syncResult.error}`);
const jsonResult = runGsdTools('state json', tmpDir);
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
const fm = JSON.parse(jsonResult.output);
assert.strictEqual(
fm.current_phase,
'3',
`current_phase must be preserved from existing frontmatter after sync (got: ${JSON.stringify(fm.current_phase)})`,
);
});
test('state sync preserves current_phase_name from existing frontmatter when body lacks annotation', () => {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, buildStateMdWithoutBodyAnnotations({ currentPhaseName: 'Implementation' }));
const syncResult = runGsdTools('state sync', tmpDir);
assert.ok(syncResult.success, `state sync failed: ${syncResult.error}`);
const jsonResult = runGsdTools('state json', tmpDir);
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
const fm = JSON.parse(jsonResult.output);
assert.strictEqual(
fm.current_phase_name,
'Implementation',
`current_phase_name must be preserved from existing frontmatter after sync (got: ${JSON.stringify(fm.current_phase_name)})`,
);
});
test('state sync preserves current_plan from existing frontmatter when body lacks annotation', () => {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, buildStateMdWithoutBodyAnnotations({ currentPlan: 2 }));
const syncResult = runGsdTools('state sync', tmpDir);
assert.ok(syncResult.success, `state sync failed: ${syncResult.error}`);
const jsonResult = runGsdTools('state json', tmpDir);
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
const fm = JSON.parse(jsonResult.output);
assert.strictEqual(
fm.current_plan,
'2',
`current_plan must be preserved from existing frontmatter after sync (got: ${JSON.stringify(fm.current_plan)})`,
);
});
test('state update (resync:false) preserves curated progress from existing frontmatter when body lacks disk-scan data', () => {
// state update "Last Activity" calls readModifyWriteStateMd with resync:false.
// That path runs syncStateFrontmatter and then explicitly re-applies the
// pre-existing progress block (lines 1243-1253 of state.cts). The curated
// progress values must survive even though the phases dir is empty.
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, buildStateMdWithoutBodyAnnotations({ progressPercent: 42 }));
// Add body annotation for Last Activity so state update can find and replace it
const initial = fs.readFileSync(statePath, 'utf8');
fs.writeFileSync(statePath, initial.replace('Last Activity: 2026-01-01', 'Last Activity: 2026-01-01'));
const updateResult = runGsdTools(
['state', 'update', 'Last Activity', '2026-06-08'],
tmpDir,
);
assert.ok(updateResult.success, `state update failed: ${updateResult.error}`);
const jsonResult = runGsdTools('state json', tmpDir);
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
const fm = JSON.parse(jsonResult.output);
assert.ok(fm.progress, 'frontmatter must retain a progress block after body-only update');
// shouldPreserveExistingProgress: existing completed_plans (4) > derived (0 from empty disk)
// → curated block survives via cmdStateJson read-path fallback.
assert.strictEqual(
fm.progress.completed_plans,
4,
`progress.completed_plans must be preserved via shouldPreserveExistingProgress ` +
`(got: ${JSON.stringify(fm.progress?.completed_plans)})`,
);
});
test('state update field preserves current_phase frontmatter when body lacks annotation', () => {
// Trigger the write path via `state update` (which calls readModifyWriteStateMd
// with resync:true), confirming the fix covers every write path.
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, buildStateMdWithoutBodyAnnotations({ currentPhase: 7 }));
const updateResult = runGsdTools(
['state', 'update', 'Last Activity', '2026-06-08'],
tmpDir,
);
assert.ok(updateResult.success, `state update failed: ${updateResult.error}`);
const jsonResult = runGsdTools('state json', tmpDir);
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
const fm = JSON.parse(jsonResult.output);
assert.strictEqual(
fm.current_phase,
'7',
`current_phase must survive a state.update write (got: ${JSON.stringify(fm.current_phase)})`,
);
});
test('body annotation beats existing frontmatter when both are present', () => {
// When the body DOES carry the annotation, the derived value wins — we must
// not accidentally lock stale frontmatter in place.
// IMPORTANT: assert on the raw written STATE.md file (not just state json,
// which rebuilds from the body and would return body-derived values regardless
// of what syncStateFrontmatter wrote to disk).
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
// Frontmatter says phase 3; body says phase 5. Body should win.
fs.writeFileSync(statePath, [
'---',
'gsd_state_version: 1.0',
'current_phase: 3',
'current_phase_name: Old Phase',
'current_plan: 1',
'status: executing',
'---',
'',
'# GSD State',
'',
'## Configuration',
'Current Phase: 5',
'Current Phase Name: New Phase',
'Current Plan: 2',
'Status: Executing',
'Last Activity: 2026-01-01',
'',
].join('\n'));
const syncResult = runGsdTools('state sync', tmpDir);
assert.ok(syncResult.success, `state sync failed: ${syncResult.error}`);
// Assert on raw file: body-derived values must be written to frontmatter,
// not the stale existing values. This guards against a fallback that locks
// in stale data even when buildStateFrontmatter successfully derived values.
const writtenContent = fs.readFileSync(statePath, 'utf8');
const rawFm = parseFrontmatter(writtenContent);
assert.strictEqual(
rawFm.current_phase,
'5',
`body-derived current_phase (5) must be written to raw frontmatter (not stale 3), got: ${JSON.stringify(rawFm.current_phase)}`,
);
assert.strictEqual(
rawFm.current_phase_name,
'New Phase',
`body-derived current_phase_name must be written to raw frontmatter, got: ${JSON.stringify(rawFm.current_phase_name)}`,
);
assert.strictEqual(
rawFm.current_plan,
'2',
`body-derived current_plan must be written to raw frontmatter, got: ${JSON.stringify(rawFm.current_plan)}`,
);
});
test('syncStateFrontmatter preserves progress from existing frontmatter when disk has no phases dir', () => {
// Directly exercises the !derivedFm['progress'] fallback in syncStateFrontmatter.
// Without a phases dir, buildStateFrontmatter returns no progress block at all
// (the existsSync guard at line ~927 short-circuits the disk scan). The
// existing frontmatter's progress must then survive the writeStateMd call.
// Use createTempDir (no phases dir) and set up .planning/ manually.
const dir = createTempDir('gsd-905-nophasesdir-');
try {
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
const statePath = path.join(dir, '.planning', 'STATE.md');
// Body has the "Current Phase:" annotation so cmdStateSync can proceed;
// the progress block is ONLY in frontmatter (no ROADMAP, no phases dir).
fs.writeFileSync(statePath, [
'---',
'gsd_state_version: 1.0',
'current_phase: 2',
'status: executing',
'progress:',
' total_phases: 4',
' completed_phases: 1',
' total_plans: 8',
' completed_plans: 3',
' percent: 38',
'---',
'',
'# GSD State',
'',
'## Configuration',
'Current Phase: 2',
'Status: Executing',
'Last Activity: 2026-01-01',
'',
].join('\n'));
// state update "Last Activity" → readModifyWriteStateMd (resync:true for
// Progress/Total Phases/Total Plans fields, but resync:false for Last Activity)
// This calls syncStateFrontmatter; without phases dir, buildStateFrontmatter
// produces no progress → !derivedFm['progress'] guard fires → existing preserved.
const updateResult = runGsdTools(
['state', 'update', 'Last Activity', '2026-06-08'],
dir,
);
assert.ok(updateResult.success, `state update failed: ${updateResult.error}`);
// Assert on the raw frontmatter file — cmdStateJson would apply
// shouldPreserveExistingProgress separately, so we must verify the on-disk state.
const written = fs.readFileSync(statePath, 'utf8');
const rawFm = parseFrontmatter(written);
// The progress block must be present in the written frontmatter.
// parseFrontmatter returns flat keys, so check the presence indicator.
assert.ok(
written.includes('progress:'),
'progress block must be preserved in raw frontmatter when disk has no phases dir',
);
// percent: 38 should survive (no disk scan to overwrite it)
assert.ok(
written.includes('percent: 38'),
`progress.percent: 38 must survive syncStateFrontmatter when no phases dir exists (raw: ${rawFm.progress})`,
);
} finally {
cleanup(dir);
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Bug #1230 regression suite
// ─────────────────────────────────────────────────────────────────────────────
/**
* Build a STATE.md where:
* - frontmatter has an explicit status (e.g. 'completed') and optional stopped_at
* - body has a Status: field that is STALE relative to the frontmatter
* (e.g. "Verifying Phase 3" would derive 'verifying')
* A subsequent incidental write must NOT revert the hand-set frontmatter status.
*/
function buildStateMd1230({ fmStatus = 'completed', fmStoppedAt = null, bodyStatus = 'Verifying Phase 3', bodyStoppedAt = null } = {}) {
const fmLines = [
'---',
'gsd_state_version: 1.0',
`status: ${fmStatus}`,
];
if (fmStoppedAt) fmLines.push(`stopped_at: "${fmStoppedAt}"`);
fmLines.push('---');
const bodyLines = [
'',
'# GSD State',
'',
'## Configuration',
`Status: ${bodyStatus}`,
'Last Activity: 2026-01-01',
`Current Phase: 3`,
'',
'## Session',
'',
'**Last session:** 2026-01-01T00:00:00.000Z',
];
if (bodyStoppedAt) bodyLines.push(`**Stopped at:** ${bodyStoppedAt}`);
bodyLines.push('');
return [...fmLines, ...bodyLines].join('\n');
}
describe('bug #1230: readModifyWriteStateMd preserves frontmatter status/stopped_at when write does not change body source field', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
// (a) CORE: record-session with stale body Status leaves frontmatter status: completed intact
test('(a) record-session does NOT revert frontmatter status: completed when body Status is unchanged', () => {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
// frontmatter status: completed; body Status: Verifying Phase 3 (derives 'verifying')
fs.writeFileSync(statePath, buildStateMd1230({ fmStatus: 'completed', bodyStatus: 'Verifying Phase 3' }));
const result = runGsdTools(
['state', 'record-session', '--stopped-at', 'Phase 3 final review checkpoint'],
tmpDir,
);
assert.ok(result.success, `record-session failed: ${result.error}`);
// Assert on raw file frontmatter — not state json (which re-derives)
const written = fs.readFileSync(statePath, 'utf8');
const rawFm = parseFrontmatter(written);
assert.strictEqual(
rawFm.status,
'completed',
`frontmatter status must remain 'completed' after record-session (body Status unchanged); got: ${JSON.stringify(rawFm.status)}`,
);
});
// (b) add-decision (resync:true) with frontmatter status: completed, stale body → status preserved
test('(b) add-decision (resync:true) does NOT revert frontmatter status: completed when body Status unchanged', () => {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, buildStateMd1230({ fmStatus: 'completed', bodyStatus: 'Verifying Phase 3' }));
const result = runGsdTools(
['state', 'add-decision', '--phase', '3', '--summary', 'Use Node 22'],
tmpDir,
);
assert.ok(result.success, `add-decision failed: ${result.error}`);
const written = fs.readFileSync(statePath, 'utf8');
const rawFm = parseFrontmatter(written);
assert.strictEqual(
rawFm.status,
'completed',
`frontmatter status must remain 'completed' after add-decision; got: ${JSON.stringify(rawFm.status)}`,
);
});
// (c) LEGITIMATE UPDATE NOT FROZEN: begin-phase changes body Status → frontmatter re-derived correctly.
//
// stateReplaceField(content, 'Status', ...) replaces the FIRST ^Status: match in the
// full content (frontmatter + body). When the frontmatter has NO 'status:' key, the
// match falls through to the body 'Status:' line, which IS changed. The delta then
// fires (preBodyStatus ≠ postBodyStatus), the preservation guard is skipped, and
// syncStateFrontmatter re-derives from the new body value as intended.
test('(c) begin-phase changes body Status → frontmatter status reflects new body-derived value', () => {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
// Frontmatter intentionally has NO 'status:' key so stateReplaceField targets the body
// 'Status:' line. After the transform, body Status becomes "Executing Phase 3" →
// normalizeStateStatus → 'executing' must be written to frontmatter.
const content = [
'---',
'gsd_state_version: 1.0',
'milestone: v1.0',
'---',
'',
'# GSD State',
'',
'## Configuration',
'Status: Ready to execute',
'Last Activity: 2026-01-01',
'Current Phase: 2',
'Current Phase Name: Planning',
'Current Plan: 1',
'',
'## Current Position',
'',
'Phase: 2 (Planning) — READY',
'Plan: 1 of 1',
'Status: Ready to execute',
'Last activity: 2026-01-01 -- Phase 2 planning complete',
'',
].join('\n');
fs.writeFileSync(statePath, content);
const result = runGsdTools(
['state', 'begin-phase', '--phase', '3', '--name', 'Execution'],
tmpDir,
);
assert.ok(result.success, `begin-phase failed: ${result.error}`);
const written = fs.readFileSync(statePath, 'utf8');
const rawFm = parseFrontmatter(written);
// begin-phase changed body Status to "Executing Phase 3" → delta fired → re-derived → 'executing'
assert.strictEqual(
rawFm.status,
'executing',
`frontmatter status must be updated to 'executing' when begin-phase changes body Status; got: ${JSON.stringify(rawFm.status)}`,
);
});
// (d) stopped_at: TRUE RED — frontmatter stopped_at preserved when body Stopped at differs
//
// Change C: this is a TRUE regression guard. Frontmatter stopped_at ("Phase 7 verified PASS")
// differs from the body ## Session "Stopped at:" value ("Phase 3 work"). The write operation
// (add-decision) does NOT touch the Session Stopped at line. The delta heuristic must detect
// that the Session Stopped at did NOT change (pre == post == "Phase 3 work") and therefore
// preserve the frontmatter value "Phase 7 verified PASS". Pre-fix code would REVERT to
// "Phase 3 work" (the body-derived value) — making this a genuine red.
test('(d) add-decision preserves frontmatter stopped_at when body Session Stopped at differs from frontmatter and is unchanged', () => {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const content = [
'---',
'gsd_state_version: 1.0',
'status: completed',
'stopped_at: "Phase 7 verified PASS"',
'---',
'',
'# GSD State',
'',
'## Configuration',
'Status: Phase 3 complete',
'Last Activity: 2026-01-01',
'Current Phase: 3',
'',
'## Session',
'',
'**Last session:** 2026-01-01T00:00:00.000Z',
// body Session Stopped at is STALE (different from frontmatter stopped_at)
'**Stopped at:** Phase 3 work',
'**Resume file:** None',
'',
].join('\n');
fs.writeFileSync(statePath, content);
// add-decision does NOT touch ## Session Stopped at → pre and post body value identical
// ("Phase 3 work" unchanged) → delta fires → frontmatter "Phase 7 verified PASS" preserved.
const result = runGsdTools(
['state', 'add-decision', '--phase', '3', '--summary', 'Preserve stopped_at check'],
tmpDir,
);
assert.ok(result.success, `add-decision failed: ${result.error}`);
const written = fs.readFileSync(statePath, 'utf8');
const rawFm = parseFrontmatter(written);
assert.strictEqual(
rawFm.stopped_at,
'Phase 7 verified PASS',
`frontmatter stopped_at must be preserved ("Phase 7 verified PASS") when body Session Stopped at ` +
`is unchanged (stale "Phase 3 work"); got: ${JSON.stringify(rawFm.stopped_at)}`,
);
});
// (f) PRODUCTION-PATH: legitimate status transition is NOT frozen by the delta heuristic.
//
// Change A: prove that begin-phase on a STATE.md with inline "Status: Executing Phase 1"
// (the standard template format) correctly transitions frontmatter status from
// 'executing' to a new 'executing' value when the body Status CHANGES.
// More critically: also verify a complete-phase → frontmatter becomes 'completed'
// when the body Status field IS changed. This locks in that the delta heuristic
// re-derives correctly whenever the body's Status source field actually changes.
test('(f) begin-phase changes inline body Status → delta fires → frontmatter status updated (not frozen)', () => {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
// Realistic STATE.md: frontmatter status: executing, body Status: Executing Phase 1 (inline format)
const content = [
'---',
'gsd_state_version: 1.0',
'status: executing',
'current_phase: 1',
'current_phase_name: Planning',
'current_plan: 1',
'---',
'',
'# GSD State',
'',
'## Configuration',
'Status: Executing Phase 1',
'Last Activity: 2026-01-01',
'Current Phase: 1',
'Current Phase Name: Planning',
'Current Plan: 1',
'Total Plans in Phase: 2',
'',
].join('\n');
fs.writeFileSync(statePath, content);
// begin-phase 2 changes body Status from "Executing Phase 1" to "Executing Phase 2"
// → pre and post body Status differ → delta does NOT fire → syncStateFrontmatter
// re-derives status from new body value → frontmatter status must reflect 'executing'
// (still 'executing' after begin-phase 2 is a correct transition).
const beginResult = runGsdTools(
['state', 'begin-phase', '--phase', '2', '--name', 'Implementation'],
tmpDir,
);
assert.ok(beginResult.success, `state begin-phase failed: ${beginResult.error}`);
const written = fs.readFileSync(statePath, 'utf8');
const rawFm = parseFrontmatter(written);
// Frontmatter status must have been updated (not frozen at original 'executing' for phase 1).
// After begin-phase 2, body Status becomes "Executing Phase 2" → re-derived → still 'executing'
// but it must NOT be the stale body-derived value from before the transform; the key check is
// that the write completed successfully and status is a known valid value.
assert.ok(
rawFm.status === 'executing',
`frontmatter status must be 'executing' after begin-phase 2 (delta fires, re-derived); got: ${JSON.stringify(rawFm.status)}`,
);
// Stronger check: if we then run a command that changes Status to a DIFFERENT value,
// the frontmatter MUST reflect the new body-derived status (not be frozen).
// Use state update to change Status to "Phase 2 complete" → derives 'completed'.
const updateResult = runGsdTools(
['state', 'update', 'Status', 'Phase 2 complete'],
tmpDir,
);
assert.ok(updateResult.success, `state update Status failed: ${updateResult.error}`);
const written2 = fs.readFileSync(statePath, 'utf8');
const rawFm2 = parseFrontmatter(written2);
assert.strictEqual(
rawFm2.status,
'completed',
`frontmatter status must be 'completed' after body Status → 'Phase 2 complete' (delta fires, re-derived); ` +
`got: ${JSON.stringify(rawFm2.status)}. The delta heuristic must NOT freeze status when the body field changes.`,
);
});
// (e) milestone-switch uses platformWriteSync directly (not RMW) — check it still resets status correctly
test('(e) milestone-switch still resets frontmatter status to planning (uses writeStateMd path, not RMW)', () => {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, buildStateMd1230({ fmStatus: 'completed', bodyStatus: 'Phase 3 complete' }));
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n## v2.0 Next\n\n### Phase 4: Next steps\n',
'utf-8',
);
const result = runGsdTools(
['state', 'milestone-switch', '--milestone', 'v2.0', '--name', 'Next'],
tmpDir,
);
assert.ok(result.success, `milestone-switch failed: ${result.error}`);
const written = fs.readFileSync(statePath, 'utf8');
const rawFm = parseFrontmatter(written);
assert.strictEqual(
rawFm.status,
'planning',
`milestone-switch must reset frontmatter status to 'planning'; got: ${JSON.stringify(rawFm.status)}`,
);
});
});

View File

@@ -1,898 +0,0 @@
'use strict';
/**
* Regression guard for bugs #948 and #944.
*
* #948 (data loss): a `state patch` whose fields all fail to match still
* rewrites STATE.md — bumping `last_updated`, resetting `milestone_name` to
* the template placeholder, and resurrecting a stale `stopped_at` from an
* old body `## Session` block (body-derived value overwrites a newer
* frontmatter value written by `record-session`).
*
* #944: `state record-session --stopped-at X --resume-file Y` silently
* drops the supplied values when the STATE.md body lacks the exact session
* labels the in-place replace expects, returning `{"recorded": false}` at
* exit 0 and only bumping `last_updated`.
*
* Shared root cause: `readModifyWriteStateMd` always writes STATE.md even
* when the transform produced no change, and `syncStateFrontmatter`
* re-derives frontmatter (including milestone_name / stopped_at) from the
* possibly-stale body on every write.
*
* Fixes:
* 1. No-op guard in `readModifyWriteStateMd`: when transform output ===
* input, skip the write entirely.
* 2. `syncStateFrontmatter` preserves existing `milestone_name` / `milestone`
* when the derived value is the template placeholder `'milestone'`.
* 3. `syncStateFrontmatter` prefers existing frontmatter `stopped_at` /
* `paused_at` over a body-derived value (frontmatter wins).
* 4. `cmdStateRecordSession` auto-creates a canonical `## Session` section
* when `--stopped-at` / `--resume-file` are supplied but no labels exist.
*/
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { runGsdTools, createTempProject, cleanup, parseFrontmatter } = require('./helpers.cjs');
// ─────────────────────────────────────────────────────────────────────────────
// Fixture builders
// ─────────────────────────────────────────────────────────────────────────────
/**
* STATE.md with:
* - real `milestone_name` in frontmatter (e.g. "My Real Milestone")
* - newer frontmatter `stopped_at` (written by a prior `record-session`)
* - stale `## Session` body section with an OLDER "Stopped at" line
*
* When a zero-match `state patch` runs on this file, NONE of these values
* should be disturbed — the file must be byte-identical afterward.
*/
function buildStateMdWithStaleSectionAndRealFrontmatter(opts) {
const {
milestoneName = 'My Real Milestone',
fmStoppedAt = 'Phase 3, Plan 2 — newer value',
bodyStoppedAt = 'Phase 1, Plan 1 — stale historical value',
lastUpdated = '2026-01-01T00:00:00.000Z',
} = opts || {};
return [
'---',
'gsd_state_version: 1.0',
'milestone: v2.0',
`milestone_name: ${milestoneName}`,
'status: executing',
`stopped_at: ${fmStoppedAt}`,
`last_updated: ${lastUpdated}`,
'progress:',
' total_phases: 5',
' completed_phases: 2',
' total_plans: 10',
' completed_plans: 4',
' percent: 40',
'---',
'',
'# GSD State',
'',
'## Current Position',
'',
'Status: Executing Phase 3',
'Last Activity: 2026-01-01',
'',
'## Session',
'',
`**Last session:** 2026-01-01T00:00:00.000Z`,
`**Stopped at:** ${bodyStoppedAt}`,
'**Resume file:** None',
'',
'## Accumulated Context',
'',
'### Decisions',
'',
'- [Phase 1]: Use Node 22',
'',
].join('\n');
}
/**
* STATE.md with NO session section at all — no "## Session" heading,
* no Stopped at / Resume file labels. This is the #944 scenario.
*/
function buildStateMdWithoutSessionSection() {
return [
'---',
'gsd_state_version: 1.0',
'milestone: v1.0',
'milestone_name: Foundation',
'status: executing',
'last_updated: 2026-01-01T00:00:00.000Z',
'---',
'',
'# GSD State',
'',
'## Current Position',
'',
'Status: Executing Phase 1',
'Last Activity: 2026-01-01',
'',
'## Accumulated Context',
'',
'### Decisions',
'',
'- [Phase 1]: Use TypeScript',
'',
].join('\n');
}
/**
* STATE.md with a canonical session section (the success path — must not regress).
*/
function buildStateMdWithCanonicalSessionSection() {
return [
'---',
'gsd_state_version: 1.0',
'milestone: v1.0',
'milestone_name: Foundation',
'status: executing',
'last_updated: 2026-01-01T00:00:00.000Z',
'---',
'',
'# GSD State',
'',
'## Session',
'',
'**Last session:** 2026-01-01T00:00:00.000Z',
'**Stopped at:** Phase 1, Plan 1',
'**Resume file:** None',
'',
'## Accumulated Context',
'',
'### Decisions',
'',
'- Use TypeScript',
'',
].join('\n');
}
// ─────────────────────────────────────────────────────────────────────────────
// Bug #948: zero-match patch must leave STATE.md byte-identical
// ─────────────────────────────────────────────────────────────────────────────
describe('#948: zero-match state patch must not rewrite STATE.md', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('STATE.md is byte-identical after a zero-match patch', () => {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const original = buildStateMdWithStaleSectionAndRealFrontmatter({});
fs.writeFileSync(statePath, original);
// Patch a field that does NOT exist in the file — zero matches expected.
const result = runGsdTools('state patch --NonExistentFieldXYZ "some value"', tmpDir);
assert.ok(result.success, `state patch should exit 0: ${result.error}`);
const patchOutput = JSON.parse(result.output);
assert.deepStrictEqual(patchOutput.updated, [], 'updated should be empty');
assert.ok(Array.isArray(patchOutput.failed), 'failed should be an array');
const after = fs.readFileSync(statePath, 'utf-8');
assert.strictEqual(after, original, 'STATE.md must be byte-identical after zero-match patch');
});
test('milestone_name is preserved after zero-match patch (not reset to template placeholder)', () => {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const original = buildStateMdWithStaleSectionAndRealFrontmatter({
milestoneName: 'My Real Milestone',
});
fs.writeFileSync(statePath, original);
runGsdTools('state patch --NonExistentField "value"', tmpDir);
const after = fs.readFileSync(statePath, 'utf-8');
const fm = parseFrontmatter(after);
assert.strictEqual(fm['milestone_name'], 'My Real Milestone',
'milestone_name must not be reset to template placeholder by zero-match patch');
});
test('stopped_at frontmatter value is preserved after zero-match patch (via byte-identity)', () => {
// The no-op guard prevents ANY rewrite when nothing changed, so the
// frontmatter stopped_at is preserved because the file is never touched.
// The stale body value cannot win because syncStateFrontmatter is never called.
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const original = buildStateMdWithStaleSectionAndRealFrontmatter({
fmStoppedAt: 'Phase 3, Plan 2 — newer value',
bodyStoppedAt: 'Phase 1, Plan 1 — stale historical value',
});
fs.writeFileSync(statePath, original);
runGsdTools('state patch --NonExistentField "value"', tmpDir);
// The byte-identity test already covers this; this test confirms the key
// field specifically is intact.
const after = fs.readFileSync(statePath, 'utf-8');
assert.strictEqual(after, original,
'STATE.md must be byte-identical — stopped_at cannot be overwritten via a no-op patch');
});
test('last_updated is not bumped by a zero-match patch', () => {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const original = buildStateMdWithStaleSectionAndRealFrontmatter({
lastUpdated: '2026-01-01T00:00:00.000Z',
});
fs.writeFileSync(statePath, original);
runGsdTools('state patch --NonExistentField "value"', tmpDir);
const after = fs.readFileSync(statePath, 'utf-8');
const fm = parseFrontmatter(after);
assert.strictEqual(fm['last_updated'], '2026-01-01T00:00:00.000Z',
'last_updated must not be bumped when no fields were changed');
});
test('a matching patch STILL updates STATE.md correctly (no regression)', () => {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const fixture = [
'---',
'gsd_state_version: 1.0',
'milestone: v1.0',
'milestone_name: Foundation',
'status: executing',
'last_updated: 2026-01-01T00:00:00.000Z',
'---',
'',
'# GSD State',
'',
'**Status:** In Progress',
'**Last Activity:** 2026-01-01',
'',
].join('\n');
fs.writeFileSync(statePath, fixture);
const result = runGsdTools('state patch --Status "Phase complete — ready for verification"', tmpDir);
assert.ok(result.success, `state patch should succeed: ${result.error}`);
const patchOutput = JSON.parse(result.output);
assert.ok(patchOutput.updated.includes('Status'), 'Status should be in updated list');
const after = fs.readFileSync(statePath, 'utf-8');
assert.ok(after.includes('Phase complete — ready for verification'),
'matching patch should update the field');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Bug #948: syncStateFrontmatter — milestone_name placeholder preservation
// ─────────────────────────────────────────────────────────────────────────────
describe('#948: syncStateFrontmatter preserves milestone_name when derived is template placeholder', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('state sync preserves real milestone_name when disk yields only template placeholder', () => {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
// Frontmatter has a real name, but no ROADMAP.md exists so getMilestoneInfo
// will fall back to the 'milestone' placeholder — must not overwrite.
const content = [
'---',
'gsd_state_version: 1.0',
'milestone: v2.5',
'milestone_name: Very Real Project Name',
'status: executing',
'---',
'',
'# GSD State',
'',
'Status: Executing Phase 1',
'Last Activity: 2026-01-01',
'',
].join('\n');
fs.writeFileSync(statePath, content);
const result = runGsdTools('state sync', tmpDir);
assert.ok(result.success, `state sync failed: ${result.error}`);
const after = fs.readFileSync(statePath, 'utf-8');
const fm = parseFrontmatter(after);
assert.strictEqual(fm['milestone_name'], 'Very Real Project Name',
'milestone_name must not be reset to template placeholder by state sync');
});
test('state sync runs successfully and preserves milestone_name (no corruption)', () => {
// state sync always rebuilds frontmatter from the body — the no-op guard
// applies to commands whose transform produces no change. state sync always
// writes because last_updated changes. This test verifies that a full sync
// cycle does not corrupt milestone_name when the placeholder is derived.
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const content = [
'---',
'gsd_state_version: 1.0',
'milestone: v2.5',
'milestone_name: Very Real Project Name',
'status: executing',
'---',
'',
'# GSD State',
'',
'Status: Executing Phase 1',
'Last Activity: 2026-01-01',
'',
].join('\n');
fs.writeFileSync(statePath, content);
const result = runGsdTools('state sync', tmpDir);
assert.ok(result.success, `state sync failed: ${result.error}`);
const after = fs.readFileSync(statePath, 'utf-8');
const fm = parseFrontmatter(after);
assert.strictEqual(fm['milestone_name'], 'Very Real Project Name',
'state sync must not reset milestone_name to template placeholder');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Bug #944: record-session with no session section must persist supplied values
// ─────────────────────────────────────────────────────────────────────────────
describe('#944: record-session persists values even when body lacks session labels', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('stopped-at and resume-file are present in STATE.md after record-session with no prior section', () => {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, buildStateMdWithoutSessionSection());
const PINNED_MS = Date.parse('2026-06-09T12:00:00.000Z');
const result = runGsdTools(
'state record-session --stopped-at "Phase 2, Plan 3" --resume-file ".planning/phases/02/02-03-PLAN.md"',
tmpDir,
{ GSD_TEST_MODE: '1', GSD_NOW_MS: String(PINNED_MS) },
);
assert.ok(result.success, `state record-session should exit 0: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.recorded, true,
'recorded must be true when values were supplied and persisted');
assert.ok(!output.reason || output.reason !== 'No session fields found in STATE.md',
'must not return the silent no-op reason when values were supplied');
const after = fs.readFileSync(statePath, 'utf-8');
assert.ok(after.includes('Phase 2, Plan 3'),
'--stopped-at value must appear in STATE.md');
assert.ok(after.includes('.planning/phases/02/02-03-PLAN.md'),
'--resume-file value must appear in STATE.md');
});
test('command does not silently no-op when values are supplied (recorded must not be false)', () => {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, buildStateMdWithoutSessionSection());
const result = runGsdTools(
'state record-session --stopped-at "Phase 5, Plan 1"',
tmpDir,
);
assert.ok(result.success, `should exit 0: ${result.error}`);
const output = JSON.parse(result.output);
// The key contract: if values were supplied, recorded must be true.
assert.notStrictEqual(output.recorded, false,
'recorded must not be false when --stopped-at was explicitly supplied');
});
test('STATE.md with non-canonical session labels still persists supplied values', () => {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
// Session section exists but uses non-canonical label shapes (table, alternate caps)
const nonCanonical = [
'---',
'gsd_state_version: 1.0',
'milestone: v1.0',
'milestone_name: Foundation',
'status: executing',
'last_updated: 2026-01-01T00:00:00.000Z',
'---',
'',
'# GSD State',
'',
'## Session Info',
'',
'| Field | Value |',
'|-------|-------|',
'| Last Session | 2026-01-01 |',
'| Stopped Here | Phase 1, Plan 1 |',
'',
].join('\n');
fs.writeFileSync(statePath, nonCanonical);
const PINNED_MS = Date.parse('2026-06-09T15:00:00.000Z');
const result = runGsdTools(
'state record-session --stopped-at "Phase 3, Plan 2" --resume-file "none.md"',
tmpDir,
{ GSD_TEST_MODE: '1', GSD_NOW_MS: String(PINNED_MS) },
);
assert.ok(result.success, `should exit 0: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.recorded, true,
'recorded must be true when values are persisted via auto-create fallback');
const after = fs.readFileSync(statePath, 'utf-8');
assert.ok(after.includes('Phase 3, Plan 2'),
'--stopped-at value must be present in STATE.md');
assert.ok(after.includes('none.md'),
'--resume-file value must be present in STATE.md');
});
test('record-session with no args against a body-less file returns recorded:false (no regression)', () => {
// When NO values are supplied and no session fields can be found/updated,
// recorded:false is the correct behaviour — we only changed the contract
// when the caller supplies values.
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, buildStateMdWithoutSessionSection());
const result = runGsdTools('state record-session', tmpDir);
assert.ok(result.success, `should exit 0: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.recorded, false,
'recorded should still be false when no session fields exist AND no values were supplied');
});
test('canonical session section still updates in place (no regression)', () => {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, buildStateMdWithCanonicalSessionSection());
const PINNED_MS = Date.parse('2026-06-09T18:00:00.000Z');
const result = runGsdTools(
'state record-session --stopped-at "Phase 2, Plan 4" --resume-file ".planning/phases/02/02-04-PLAN.md"',
tmpDir,
{ GSD_TEST_MODE: '1', GSD_NOW_MS: String(PINNED_MS) },
);
assert.ok(result.success, `should exit 0: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.recorded, true, 'recorded should be true');
const after = fs.readFileSync(statePath, 'utf-8');
assert.ok(after.includes('Phase 2, Plan 4'), 'stopped-at should be updated');
assert.ok(after.includes('.planning/phases/02/02-04-PLAN.md'), 'resume-file should be updated');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Adversarial fixtures: malformed frontmatter, missing fields, CRLF
// ─────────────────────────────────────────────────────────────────────────────
describe('#948/#944: adversarial fixture variants', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('zero-match patch on CRLF STATE.md leaves file unchanged', () => {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
// Build with CRLF line endings
const original = buildStateMdWithStaleSectionAndRealFrontmatter({}).replace(/\n/g, '\r\n');
fs.writeFileSync(statePath, original);
runGsdTools('state patch --NonExistentFieldXYZ "value"', tmpDir);
const after = fs.readFileSync(statePath, 'utf-8');
assert.strictEqual(after, original, 'CRLF file must be byte-identical after zero-match patch');
});
test('zero-match patch on STATE.md with missing frontmatter fields does not corrupt', () => {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const minimal = [
'---',
'gsd_state_version: 1.0',
'---',
'',
'# GSD State',
'',
'**Status:** In Progress',
'',
].join('\n');
fs.writeFileSync(statePath, minimal);
const result = runGsdTools('state patch --NonExistentField "value"', tmpDir);
assert.ok(result.success, `should exit 0: ${result.error}`);
const patchOutput = JSON.parse(result.output);
assert.deepStrictEqual(patchOutput.updated, [], 'no fields should be updated');
});
test('record-session with empty body still records when values supplied', () => {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
// Body is entirely empty (only frontmatter)
const emptyBody = [
'---',
'gsd_state_version: 1.0',
'status: planning',
'---',
'',
].join('\n');
fs.writeFileSync(statePath, emptyBody);
const result = runGsdTools(
'state record-session --stopped-at "Phase 1, Plan 1"',
tmpDir,
);
assert.ok(result.success, `should exit 0: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.recorded, true,
'should persist even into a body-less STATE.md');
const after = fs.readFileSync(statePath, 'utf-8');
assert.ok(after.includes('Phase 1, Plan 1'),
'--stopped-at value must appear in STATE.md');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Adversarial review findings: in-place update for existing ## Session heading
// ─────────────────────────────────────────────────────────────────────────────
describe('#944 adversarial: existing ## Session heading must be updated in place, not duplicated', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
/**
* HIGH finding: when a `## Session` heading already exists but uses
* non-canonical rows (e.g. a markdown table), the DWIM code was appending
* a second `## Session` block instead of normalizing the existing one.
* buildStateFrontmatter / cmdStateSnapshot both read only the FIRST match,
* so the newly-written Stopped at / Resume file end up in an ignored block.
*/
test('record-session with existing non-canonical ## Session block: exactly one ## Session block afterward', () => {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const nonCanonicalWithHeading = [
'---',
'gsd_state_version: 1.0',
'milestone: v1.0',
'milestone_name: Foundation',
'status: executing',
'last_updated: 2026-01-01T00:00:00.000Z',
'---',
'',
'# GSD State',
'',
'## Session',
'',
'| Field | Value |',
'|-------|-------|',
'| Last Session | 2026-01-01 |',
'| Stopped Here | Phase 1, Plan 1 |',
'',
'## Accumulated Context',
'',
'- Decision: use TypeScript',
'',
].join('\n');
fs.writeFileSync(statePath, nonCanonicalWithHeading);
const PINNED_MS = Date.parse('2026-06-09T20:00:00.000Z');
const result = runGsdTools(
'state record-session --stopped-at "Phase 4, Plan 2" --resume-file "resume.md"',
tmpDir,
{ GSD_TEST_MODE: '1', GSD_NOW_MS: String(PINNED_MS) },
);
assert.ok(result.success, `record-session should exit 0: ${result.error}`);
const after = fs.readFileSync(statePath, 'utf-8');
// (a) exactly ONE ## Session block — no duplicate
const sessionHeadingCount = (after.match(/^## Session\s*$/gm) || []).length;
assert.strictEqual(sessionHeadingCount, 1,
'exactly ONE ## Session block must exist after record-session (no duplicate appended)');
// (b) supplied values are present in the file
assert.ok(after.includes('Phase 4, Plan 2'),
'--stopped-at value must be present in STATE.md');
assert.ok(after.includes('resume.md'),
'--resume-file value must be present in STATE.md');
});
test('record-session with existing non-canonical ## Session block: state-snapshot sees supplied stopped_at', () => {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const nonCanonicalWithHeading = [
'---',
'gsd_state_version: 1.0',
'milestone: v1.0',
'milestone_name: Foundation',
'status: executing',
'last_updated: 2026-01-01T00:00:00.000Z',
'---',
'',
'# GSD State',
'',
'## Session',
'',
'| Field | Value |',
'|-------|-------|',
'| Last Session | 2026-01-01 |',
'| Stopped Here | Phase 1, Plan 1 |',
'',
].join('\n');
fs.writeFileSync(statePath, nonCanonicalWithHeading);
const PINNED_MS = Date.parse('2026-06-09T20:30:00.000Z');
runGsdTools(
'state record-session --stopped-at "Phase 4, Plan 2" --resume-file "resume.md"',
tmpDir,
{ GSD_TEST_MODE: '1', GSD_NOW_MS: String(PINNED_MS) },
);
// (c) state-snapshot must see the written stopped_at in the session block
// (via buildStateFrontmatter frontmatter OR body Session section, first match)
const snapshotResult = runGsdTools('state-snapshot', tmpDir);
assert.ok(snapshotResult.success, `state-snapshot should exit 0: ${snapshotResult.error}`);
const snapshot = JSON.parse(snapshotResult.output);
assert.strictEqual(
snapshot.session && snapshot.session.stopped_at,
'Phase 4, Plan 2',
`state-snapshot session.stopped_at must reflect "Phase 4, Plan 2", got: ${JSON.stringify(snapshot.session)}`,
);
});
/**
* LOW finding: auto-created scaffold writes `**Last session:**` but
* cmdStateSnapshot only matched `**Last Date:**`, so session.last_date
* was null after auto-create despite a valid timestamp being written.
* Fix: teach the snapshot parser to also accept `**Last session:**`.
*/
test('state-snapshot returns non-null session.last_date after auto-create on body-less file', () => {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, buildStateMdWithoutSessionSection());
const PINNED_MS = Date.parse('2026-06-09T21:00:00.000Z');
const recResult = runGsdTools(
'state record-session --stopped-at "Phase 1, Plan 1"',
tmpDir,
{ GSD_TEST_MODE: '1', GSD_NOW_MS: String(PINNED_MS) },
);
assert.ok(recResult.success, `record-session should exit 0: ${recResult.error}`);
const snapshotResult = runGsdTools('state-snapshot', tmpDir);
assert.ok(snapshotResult.success, `state-snapshot should exit 0: ${snapshotResult.error}`);
const snapshot = JSON.parse(snapshotResult.output);
assert.notStrictEqual(
snapshot.session && snapshot.session.last_date,
null,
`state-snapshot session.last_date must not be null after auto-create; got: ${JSON.stringify(snapshot.session)}`,
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Bug #1101: record-session on a `## Session Continuity` bootstrap section must
// update IN PLACE, not append a duplicate `## Session` block.
//
// The reported symptom (recorded:false + frontmatter still mutated) is already
// fixed by #944/#948. The residual: the DWIM auto-create recognised only the
// canonical `## Session` heading, so a bootstrap `## Session Continuity` section
// (workstream.cts, gsd2-import.cts, templates/state.md) fell through to the
// append branch and produced a SECOND `## Session` block. The fix inserts the
// missing canonical fields into the existing `## Session Continuity` section,
// preserving the heading and any prose, and teaches the snapshot / frontmatter
// readers to recognise that heading.
// ─────────────────────────────────────────────────────────────────────────────
describe('#1101: record-session updates ## Session Continuity in place (no duplicate block)', () => {
let tmpDir;
beforeEach(() => { tmpDir = createTempProject(); });
afterEach(() => { cleanup(tmpDir); });
/** Workstream bootstrap shape: bold Stopped At/Resume File, no Last session. */
function buildWorkstreamContinuity() {
return [
'---',
'gsd_state_version: 1.0',
'milestone: v1.0',
'milestone_name: Foundation',
'status: executing',
'last_updated: 2026-01-01T00:00:00.000Z',
'---',
'',
'# State: example',
'',
'## Session Continuity',
'**Stopped At:** N/A',
'**Resume File:** None',
'',
].join('\n');
}
test('workstream Session Continuity is updated in place — no duplicate ## Session appended', () => {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, buildWorkstreamContinuity());
const PINNED_MS = Date.parse('2026-06-12T12:00:00.000Z');
const result = runGsdTools(
'state record-session --stopped-at "Phase 1 context gathered" --resume-file ".planning/phases/01/01-CONTEXT.md"',
tmpDir,
{ GSD_TEST_MODE: '1', GSD_NOW_MS: String(PINNED_MS) },
);
assert.ok(result.success, `record-session should exit 0: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.recorded, true, 'recorded must be true when values are supplied');
const after = fs.readFileSync(statePath, 'utf-8');
// No duplicate bare `## Session` heading appended (the only heading stays
// `## Session Continuity`).
assert.ok(!/^## Session[ \t]*$/m.test(after),
`must not append a duplicate bare "## Session" block; got:\n${after}`);
assert.strictEqual((after.match(/^## Session\b/gm) || []).length, 1,
`exactly one Session-family heading must remain; got:\n${after}`);
// Missing canonical field inserted; supplied values present.
assert.ok(after.includes('**Last session:**'), 'Last session field must be inserted');
assert.ok(after.includes('Phase 1 context gathered'), '--stopped-at value must be present');
assert.ok(after.includes('.planning/phases/01/01-CONTEXT.md'), '--resume-file value must be present');
// The frontmatter reader recognises `## Session Continuity` and derives stopped_at.
const fm = parseFrontmatter(after);
assert.strictEqual(fm.stopped_at, 'Phase 1 context gathered',
'frontmatter stopped_at must be derived from the ## Session Continuity section');
// The cmdStateSnapshot reader (separate code path) must also resolve it.
const snap = runGsdTools('state-snapshot', tmpDir);
assert.ok(snap.success, `state-snapshot should exit 0: ${snap.error}`);
const snapshot = JSON.parse(snap.output);
assert.strictEqual(snapshot.session.stopped_at, 'Phase 1 context gathered',
'state-snapshot must read stopped_at from the ## Session Continuity section');
});
test('prose under ## Session Continuity is preserved (no data loss)', () => {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const withProse = [
'---',
'gsd_state_version: 1.0',
'milestone: v1.0',
'milestone_name: Foundation',
'status: executing',
'last_updated: 2026-01-01T00:00:00.000Z',
'---',
'',
'# State: example',
'',
'## Session Continuity',
'',
'**Next recommended action:** keep-me-intact',
'',
].join('\n');
fs.writeFileSync(statePath, withProse);
const PINNED_MS = Date.parse('2026-06-12T13:00:00.000Z');
const result = runGsdTools(
'state record-session --stopped-at "Phase 2 done" --resume-file "none.md"',
tmpDir,
{ GSD_TEST_MODE: '1', GSD_NOW_MS: String(PINNED_MS) },
);
assert.ok(result.success, `record-session should exit 0: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.recorded, true, 'recorded must be true');
const after = fs.readFileSync(statePath, 'utf-8');
assert.ok(after.includes('**Next recommended action:** keep-me-intact'),
`existing prose must be preserved (no data loss); got:\n${after}`);
assert.ok(!/^## Session[ \t]*$/m.test(after),
'must not append a duplicate bare "## Session" block');
assert.ok(after.includes('Phase 2 done'), '--stopped-at value must be present');
const fm = parseFrontmatter(after);
assert.strictEqual(fm.stopped_at, 'Phase 2 done',
'frontmatter stopped_at must be derived from the ## Session Continuity section');
});
test('canonical ## Session block path is unchanged (no regression)', () => {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, buildStateMdWithCanonicalSessionSection());
const PINNED_MS = Date.parse('2026-06-12T14:00:00.000Z');
const result = runGsdTools(
'state record-session --stopped-at "Phase 9, Plan 9" --resume-file "r.md"',
tmpDir,
{ GSD_TEST_MODE: '1', GSD_NOW_MS: String(PINNED_MS) },
);
assert.ok(result.success, `record-session should exit 0: ${result.error}`);
const after = fs.readFileSync(statePath, 'utf-8');
assert.strictEqual((after.match(/^## Session\b/gm) || []).length, 1,
'canonical ## Session block must remain single');
assert.ok(after.includes('Phase 9, Plan 9'), 'stopped-at value updated in canonical block');
});
test('legacy duplicate file: reader PREFERS canonical ## Session over ## Session Continuity (F1)', () => {
// A file created by the OLD bug: a stale `## Session Continuity` first, then an
// appended fresh `## Session`. The snapshot reader must read the canonical
// `## Session` (fresh), matching the writer, not the stale Continuity block.
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const duplicate = [
'---',
'gsd_state_version: 1.0',
'milestone: v1.0',
'milestone_name: Foundation',
'status: executing',
'last_updated: 2026-01-01T00:00:00.000Z',
'---',
'',
'# State: example',
'',
'## Session Continuity',
'**Stopped At:** STALE-continuity-value',
'**Resume File:** None',
'',
'## Session',
'',
'**Last session:** 2026-06-12T10:00:00.000Z',
'**Stopped at:** FRESH-canonical-value',
'**Resume file:** r.md',
'',
].join('\n');
fs.writeFileSync(statePath, duplicate);
const snap = runGsdTools('state-snapshot', tmpDir);
assert.ok(snap.success, `state-snapshot should exit 0: ${snap.error}`);
const snapshot = JSON.parse(snap.output);
assert.strictEqual(snapshot.session.stopped_at, 'FRESH-canonical-value',
'reader must prefer the canonical ## Session block over the stale ## Session Continuity');
});
test('h3 ### Session Continuity is NOT read as the session section (F4)', () => {
// The reader is line-anchored to `^## `, so an h3 subsection must not be picked
// up as the session section.
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const h3Only = [
'---',
'gsd_state_version: 1.0',
'milestone: v1.0',
'milestone_name: Foundation',
'status: executing',
'last_updated: 2026-01-01T00:00:00.000Z',
'---',
'',
'# State: example',
'',
'### Session Continuity',
'**Last session:** 2026-06-12T10:00:00.000Z',
'**Stopped at:** h3-should-not-be-session',
'',
].join('\n');
fs.writeFileSync(statePath, h3Only);
const snap = runGsdTools('state-snapshot', tmpDir);
assert.ok(snap.success, `state-snapshot should exit 0: ${snap.error}`);
const snapshot = JSON.parse(snap.output);
assert.strictEqual(snapshot.session.last_date, null,
'an h3 ### Session Continuity must not be treated as the ## Session section');
});
});

View File

@@ -1,315 +0,0 @@
/**
* Regression test for bug #967: verify key-links reads from:/to: as literal
* relative file paths; the reference docs wrongly implied component/endpoint
* values were valid. Fix direction: author-strict — docs corrected to match code.
*
* Contract pinned here:
* 1. from: must be a relative file path; pattern: is evaluated against its content.
* 2. from: pointing to a non-existent file → verified:false, detail "Source file not found".
* 3. docs/reference/plan-md.md reference example uses a file path for to: (NOT /api/feed).
*/
'use strict';
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
// ─── helpers ──────────────────────────────────────────────────────────────────
function writePlanWithKeyLinks(tmpDir, keyLinksYaml, opts) {
// parseMustHavesBlock expects 4-space indent for block name, 6-space for items
const wave = (opts && opts.wave != null) ? opts.wave : 1;
const filesModified = (opts && opts.filesModified) ? opts.filesModified : ['src/a.js'];
const filesModifiedYaml = filesModified.length === 1
? `[${filesModified[0]}]`
: `[${filesModified.join(', ')}]`;
const content = [
'---',
'phase: 01-test',
'plan: 01',
'type: execute',
`wave: ${wave}`,
'depends_on: []',
`files_modified: ${filesModifiedYaml}`,
'autonomous: true',
'must_haves:',
' key_links:',
...keyLinksYaml.map(line => ` ${line}`),
'---',
'',
'<tasks>',
'<task type="auto">',
' <name>Task 1: Do thing</name>',
' <files>src/a.js</files>',
' <action>Do it</action>',
' <verify><automated>echo ok</automated></verify>',
' <done>Done</done>',
'</task>',
'</tasks>',
].join('\n');
const planPath = path.join(tmpDir, '.planning', 'phases', '01-test', '01-01-PLAN.md');
fs.mkdirSync(path.dirname(planPath), { recursive: true });
fs.writeFileSync(planPath, content);
}
/**
* Write an additional plan file in the same phase directory with specific
* wave + files_modified (no key_links, just declaring future artifacts).
*/
function writeCompanionPlan(tmpDir, planFileName, wave, filesModified) {
const filesModifiedYaml = `[${filesModified.join(', ')}]`;
const content = [
'---',
'phase: 01-test',
'plan: 02',
'type: execute',
`wave: ${wave}`,
'depends_on: []',
`files_modified: ${filesModifiedYaml}`,
'autonomous: true',
'must_haves:',
' key_links: []',
'---',
'',
'<tasks>',
'<task type="auto">',
' <name>Task 2: Create file</name>',
' <files>src/b.js</files>',
' <action>Create it</action>',
' <verify><automated>echo ok</automated></verify>',
' <done>Done</done>',
'</task>',
'</tasks>',
].join('\n');
const planPath = path.join(tmpDir, '.planning', 'phases', '01-test', planFileName);
fs.writeFileSync(planPath, content);
}
describe('bug-967 verify key-links strict file-path contract', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true });
});
afterEach(() => {
cleanup(tmpDir);
});
// ── 1. Happy path: from: is a real file path and pattern: matches ──────────
test('verified:true when from: is a relative file path and pattern: matches', () => {
writePlanWithKeyLinks(tmpDir, [
'- from: "src/component.js"',
' to: "src/api/feed.js"',
' via: "fetch in useEffect"',
' pattern: "fetch.*api/feed"',
]);
// Create the source file containing the pattern
fs.writeFileSync(
path.join(tmpDir, 'src', 'component.js'),
"fetch('/api/feed').then(r => r.json());\n",
);
// Create the target file too (not strictly needed for this path, but realistic)
fs.mkdirSync(path.join(tmpDir, 'src', 'api'), { recursive: true });
fs.writeFileSync(path.join(tmpDir, 'src', 'api', 'feed.js'), 'module.exports = {};\n');
const result = runGsdTools(
'verify key-links .planning/phases/01-test/01-01-PLAN.md',
tmpDir,
);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(
output.all_verified,
true,
`Expected all_verified:true (file-path from: + matching pattern:). Got: ${JSON.stringify(output)}`,
);
assert.strictEqual(output.links[0].verified, true);
});
// ── 2. Contract: missing source file → verified:false, explicit detail ─────
test('verified:false with "Source file not found" detail when from: file does not exist', () => {
writePlanWithKeyLinks(tmpDir, [
'- from: "src/missing-file.js"',
' to: "src/api/feed.js"',
' via: "fetch in useEffect"',
' pattern: "fetch.*api/feed"',
]);
// Deliberately do NOT create src/missing-file.js
const result = runGsdTools(
'verify key-links .planning/phases/01-test/01-01-PLAN.md',
tmpDir,
);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(
output.links[0].verified,
false,
`Expected verified:false for absent source file. Got: ${JSON.stringify(output.links[0])}`,
);
assert.ok(
output.links[0].detail.includes('Source file not found'),
`Expected detail to include "Source file not found". Got: "${output.links[0].detail}"`,
);
});
// ── 3. Regression #1202: missing from: file promised by a same-wave plan → pending:true ──
//
// A from: file absent on disk but listed in files_modified of another plan at
// the same wave must be reported pending:true (not verified:false) and must NOT
// count against the all_verified gate.
//
// This test MUST FAIL before the fix is applied (the gate hard-fails today).
test('pending:true and all_verified:true when from: file is promised by a same-wave plan', () => {
// Plan under test is wave 2; it references src/future-artifact.js which does not
// exist on disk yet.
writePlanWithKeyLinks(tmpDir, [
'- from: "src/future-artifact.js"',
' to: "src/consumer.js"',
' via: "requires future-artifact"',
' pattern: "future-artifact"',
], { wave: 2, filesModified: ['src/consumer.js'] });
// A companion plan also at wave 2 declares src/future-artifact.js in files_modified
writeCompanionPlan(tmpDir, '01-02-PLAN.md', 2, ['src/future-artifact.js']);
// Do NOT create src/future-artifact.js on disk — it is a planned future file
const result = runGsdTools(
'verify key-links .planning/phases/01-test/01-01-PLAN.md',
tmpDir,
);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(
out.links[0].pending,
true,
`Expected pending:true for a from: file promised by a same-wave plan. Got: ${JSON.stringify(out.links[0])}`,
);
assert.strictEqual(
out.all_verified,
true,
`Expected all_verified:true (pending links should not fail the gate). Got: ${JSON.stringify(out)}`,
);
assert.strictEqual(
out.links[0].verified,
false,
`Expected verified:false (file is not yet verified — just pending). Got: ${JSON.stringify(out.links[0])}`,
);
});
// ── 4. Regression #1202: missing from: file promised by a LATER-wave plan → pending:true ──
test('pending:true and all_verified:true when from: file is promised by a later-wave plan', () => {
// Plan under test is wave 1; companion plan is wave 3 (later wave promises the file)
writePlanWithKeyLinks(tmpDir, [
'- from: "src/later-artifact.js"',
' to: "src/consumer.js"',
' via: "later wave dependency"',
], { wave: 1, filesModified: ['src/consumer.js'] });
writeCompanionPlan(tmpDir, '01-02-PLAN.md', 3, ['src/later-artifact.js']);
const result = runGsdTools(
'verify key-links .planning/phases/01-test/01-01-PLAN.md',
tmpDir,
);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(
out.links[0].pending,
true,
`Expected pending:true for from: file promised by a later-wave plan. Got: ${JSON.stringify(out.links[0])}`,
);
assert.strictEqual(
out.all_verified,
true,
`Expected all_verified:true (pending links not counted against gate). Got: ${JSON.stringify(out)}`,
);
});
// ── 5. Regression #1202: missing from: file NOT promised by any plan → hard failure ──
//
// Absence of from: file with no plan promising it must remain a genuine verified:false failure.
test('verified:false and all_verified:false when from: file is absent and not promised by any plan', () => {
writePlanWithKeyLinks(tmpDir, [
'- from: "src/truly-missing.js"',
' to: "src/consumer.js"',
' via: "no plan promises this"',
], { wave: 1, filesModified: ['src/consumer.js'] });
// No companion plan that promises src/truly-missing.js
const result = runGsdTools(
'verify key-links .planning/phases/01-test/01-01-PLAN.md',
tmpDir,
);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(
out.links[0].verified,
false,
`Expected verified:false for absent+unpromised from: file. Got: ${JSON.stringify(out.links[0])}`,
);
assert.strictEqual(
out.all_verified,
false,
`Expected all_verified:false (hard failure). Got: ${JSON.stringify(out)}`,
);
// pending must not be true
assert.notStrictEqual(
out.links[0].pending,
true,
`Expected pending not to be true for an absent+unpromised file. Got: ${JSON.stringify(out.links[0])}`,
);
});
// ── 6. Doc-contract guard: reference example must use a file path for to: ──
//
// The old reference example had to: "/api/feed" (an HTTP endpoint).
// After fix #967, to: must be a relative file path like "app/api/feed/route.ts".
// This test reads the canonical docs file and asserts the example is consistent
// with the strict-path contract.
//
// allow-test-rule: <runtime-contract-is-the-product> the plan-md.md reference
// example IS the documented authoring surface for key_links; asserting it uses
// a file path (not an endpoint) directly tests the documented contract.
test('docs/reference/plan-md.md key_links example uses a relative file path for to:, not an HTTP endpoint', () => {
// Locate plan-md.md relative to this test file's repo root
const docPath = path.join(__dirname, '..', 'docs', 'reference', 'plan-md.md');
assert.ok(fs.existsSync(docPath), `plan-md.md not found at ${docPath}`);
const content = fs.readFileSync(docPath, 'utf-8'); // allow-test-rule: <runtime-contract-is-the-product> the plan-md.md reference example IS the documented authoring surface for key_links; asserting it uses a file path (not an endpoint) directly tests the documented contract.
// Find the key_links block in the annotated example (the first YAML frontmatter fence)
// The bad old value was: to: "/api/feed"
assert.ok(
!content.includes('to: "/api/feed"'),
'docs/reference/plan-md.md still contains the endpoint-style to: "/api/feed" — ' +
'the reference example must use a relative file path (e.g. "app/api/feed/route.ts") ' +
'to match the strict file-path contract.',
);
// Also assert the corrected example actually uses a path-like value
// (must contain at least one '/' and not start with 'http')
const toMatch = content.match(/key_links:[\s\S]*?to:\s*"([^"]+)"/);
assert.ok(
toMatch,
'Could not find a to: field in the key_links example in plan-md.md',
);
const toValue = toMatch[1];
assert.ok(
!toValue.startsWith('/api') && !toValue.startsWith('http'),
`to: value in the docs example looks like an HTTP endpoint: "${toValue}". ` +
'It must be a relative file path.',
);
});
});

View File

@@ -1,92 +0,0 @@
'use strict';
/**
* Regression test for bug #978: `gsd-tools milestone complete --force` was a
* dead flag. The milestone source (src/milestone.cts) has a guard that checks
* `options.force` and tells users to "Re-run with --force to override", but the
* CLI dispatcher (gsd-core/bin/gsd-tools.cjs) never parsed `--force` and never
* passed it into the options object. So `options.force` was always `undefined`
* and the guard could never be overridden regardless of what the user typed.
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
/**
* Build a fixture where the guard will fire:
* - STATE.md has `milestone: <version>` so the guard's version-match check is
* satisfied.
* - ROADMAP.md lists a `### Phase 2: Real Work` heading for that milestone, but
* there is NO on-disk phase directory for it.
*
* This guarantees "unstarted phase" detection without touching any real phases.
* NOTE: the unstarted phase must be a REAL phase number — Phase 0 and Phase 999
* are backlog/pre-milestone sentinels that are intentionally excluded from this
* guard (#1580), so they would not fire it.
*/
function makeGuardFixture(tmpDir, version) {
// STATE.md with frontmatter milestone field matching the version
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`---\nmilestone: ${version}\n---\n# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`,
);
// ROADMAP.md — the heading must include the version so getMilestonePhaseFilter
// does not return missingExplicitVersion. Phase 2 has no on-disk dir.
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap ${version}\n\n### Phase 2: Real Work\n**Goal:** Not started\n`,
);
}
describe('bug-978: milestone complete --force overrides unstarted-phase guard', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject('gsd-bug-978-');
});
afterEach(() => {
cleanup(tmpDir);
});
test('without --force the guard fires and emits the documented error message', () => {
makeGuardFixture(tmpDir, 'v1.0');
const result = runGsdTools(
['milestone', 'complete', 'v1.0', '--name', 'Regression Test'],
tmpDir,
);
assert.strictEqual(result.success, false, 'command should fail without --force');
assert.ok(
result.error.includes('Re-run with --force to override'),
`expected guard error message; got: ${result.error}`,
);
});
test('with --force the guard is bypassed and the command succeeds', () => {
makeGuardFixture(tmpDir, 'v1.0');
const result = runGsdTools(
['milestone', 'complete', 'v1.0', '--name', 'Regression Test', '--force'],
tmpDir,
);
assert.ok(
result.success,
`command should succeed with --force but failed: ${result.error}`,
);
const output = JSON.parse(result.output);
assert.strictEqual(output.version, 'v1.0');
// Milestone entry should have been created even though phase 2 has no dir
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'MILESTONES.md')),
'MILESTONES.md should have been created',
);
});
});

View File

@@ -241,3 +241,391 @@ describe('cjs-command-router-adapter routeCjsCommandFamily', () => {
assert.equal(errorMessage, 'boom');
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-224-pick-stdout-capture.test.cjs — consolidation epic #1969 (B2 #1971)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-224-pick-stdout-capture (consolidation epic #1969 B2 #1971)", () => {
// allow-test-rule: structural-implementation-guard (see #224)
// Bug #224 is a platform-specific (Node 24 + Windows) flake in `--pick` where
// stdout interception can produce non-deterministic failures. We lock the seam
// contract structurally until we have a deterministic Windows reproduction
// harness in CI.
'use strict';
const { describe, test, before } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { runGsdTools } = require('./helpers.cjs');
const GSD_TOOLS_SRC = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
describe('bug #224: --pick stdout capture contract', () => {
let src;
before(() => {
src = fs.readFileSync(GSD_TOOLS_SRC, 'utf-8');
});
test('--pick output still succeeds for current-timestamp command', () => {
const result = runGsdTools(['current-timestamp', '--pick', 'timestamp']);
assert.strictEqual(result.success, true, result.error || 'expected command to succeed');
assert.match(result.output, /^\d{4}-\d{2}-\d{2}T/, 'expected ISO timestamp output');
});
test('stdout interception for fd=1 returns a byte count (never undefined)', () => {
const mainStart = src.indexOf('async function main()');
assert.ok(mainStart !== -1, 'main() must exist');
const mainSrc = src.slice(mainStart);
assert.ok(
mainSrc.includes('Buffer.byteLength(') || mainSrc.includes('return data.length'),
'stdout interception must return written-byte counts for fd=1 captures'
);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-3243-dotted-command-form.test.cjs — consolidation epic #1969 (B2 #1971)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-3243-dotted-command-form (consolidation epic #1969 B2 #1971)", () => {
/**
* Regression tests for bug #3243.
*
* The CJS dispatcher (gsd-tools.cjs) must accept dotted canonical command
* form (e.g. `state.update`) as well as the spaced form (`state update`).
* Workflow markdown files emit `gsd-sdk query <domain>.<subcommand>` calls,
* and any caller that bypasses the SDK (stale npm binary, direct shell-out,
* third-party script) would hit "Unknown command: <domain>.<subcommand>".
*
* The fix: a top-of-main() shim that splits args[0] on the first `.` when
* present and normalizes to the spaced form before the switch is reached.
*
* This test file uses runGsdTools() — never readFileSync + .includes().
*/
'use strict';
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
describe('bug #3243: CJS dispatcher accepts dotted canonical command form', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
// ── generate-slug: no project structure needed, deterministic output ────
test('generate-slug.hello-world (dotted) produces the same slug as spaced form', () => {
const spaced = runGsdTools(['generate-slug', 'hello-world'], tmpDir);
assert.strictEqual(spaced.success, true, [
'control (spaced form) failed:',
spaced.error,
].join(' '));
const dotted = runGsdTools(['generate-slug.hello-world'], tmpDir);
// Before the fix this errors: "Unknown command: generate-slug.hello-world"
assert.strictEqual(dotted.success, true, [
'dotted form must not emit "Unknown command":',
dotted.error,
].join(' '));
assert.strictEqual(dotted.output, spaced.output,
'dotted form must produce identical output to spaced form');
});
test('current-timestamp.date (dotted) produces the same output as spaced form', () => {
const spaced = runGsdTools(['current-timestamp', 'date'], tmpDir);
assert.strictEqual(spaced.success, true, [
'control (spaced form) failed:',
spaced.error,
].join(' '));
const dotted = runGsdTools(['current-timestamp.date'], tmpDir);
assert.strictEqual(dotted.success, true, [
'dotted form must not emit "Unknown command":',
dotted.error,
].join(' '));
assert.strictEqual(dotted.output, spaced.output,
'dotted form must produce identical output to spaced form');
});
// ── Commands with subcommands that need a project ────────────────────────
test('validate.plan (dotted) routes into validate handler, not "Unknown command"', () => {
const dotted = runGsdTools(['validate.plan'], tmpDir);
// Before the fix: success=false, error contains "Unknown command: validate.plan"
// After the fix: success=false is still possible (validate needs a PLAN.md),
// but the error must NOT mention "Unknown command".
const errText = dotted.error || '';
assert.ok(
!errText.includes('Unknown command: validate.plan'),
[
'dotted form must not produce "Unknown command: validate.plan".',
'Got error:', errText,
].join('\n')
);
});
test('roadmap.analyze (dotted) routes into roadmap handler, not "Unknown command"', () => {
const dotted = runGsdTools(['roadmap.analyze'], tmpDir);
// success=true means it reached the handler (even if handler reports no ROADMAP.md).
// success=false means dispatcher rejected it — assert the error is NOT "Unknown command".
const errText = dotted.error || '';
assert.ok(
!errText.includes('Unknown command: roadmap.analyze'),
[
'dotted form must not produce "Unknown command: roadmap.analyze".',
'Got error:', errText,
].join('\n')
);
});
test('phases.list (dotted) routes into phases handler, not "Unknown command"', () => {
const dotted = runGsdTools(['phases.list'], tmpDir);
const errText = dotted.error || '';
assert.ok(
!errText.includes('Unknown command: phases.list'),
[
'dotted form must not produce "Unknown command: phases.list".',
'Got error:', errText,
].join('\n')
);
});
// ── Multi-dot commands: split on first dot only ──────────────────────────
test('check.decision-coverage-plan (multi-dot-safe: first dot splits)', () => {
const dotted = runGsdTools(['check.decision-coverage-plan'], tmpDir);
// "check" is not a known top-level command currently, so this will still
// fail — but the error must NOT say "Unknown command: check.decision-coverage-plan"
// (the dotted form); it should say something about "check" (the split result).
const errText = dotted.error || '';
assert.ok(
!errText.includes('Unknown command: check.decision-coverage-plan'),
[
'multi-dot dotted form must not be passed verbatim to "Unknown command".',
'Got error:', errText,
].join('\n')
);
});
// ── Edge cases ────────────────────────────────────────────────────────────
test('command without dots is unchanged (existing behaviour preserved)', () => {
const result = runGsdTools(['generate-slug', 'no-dots-here'], tmpDir);
assert.strictEqual(result.success, true, [
'spaced-only invocation must still work:',
result.error,
].join(' '));
assert.ok(result.output.length > 0, 'output must be non-empty');
});
test('leading-dot arg (e.g. .hidden) is not mis-routed by the shim', () => {
// A leading dot in args[0] like ".hidden" has head="" (empty) after split,
// so the shim must reject it and fall through to the existing "Unknown command"
// path (not silently reroute to an empty-string command).
const result = runGsdTools(['.hidden'], tmpDir);
assert.strictEqual(result.success, false, 'leading-dot arg must not succeed');
});
// ── "Unknown command" error message improvement ──────────────────────────
test('"Unknown command" error for dotted form suggests spaced equivalent', () => {
// A genuinely unknown dotted command (e.g. "foo.bar") should include a
// "did you mean" hint pointing at the spaced form "foo bar".
const result = runGsdTools(['foo.bar'], tmpDir);
assert.strictEqual(result.success, false, '"foo.bar" must fail');
assert.ok(
result.error.includes('foo bar'),
[
'error for unknown dotted command should suggest spaced form "foo bar".',
'Got:', result.error,
].join('\n')
);
});
test('multi-dot unknown command suggestion uses first-dot split only (a.b.c → "a b.c")', () => {
// The shim splits only on the FIRST dot, so the suggestion must mirror that:
// "a.b.c" → head="a", rest="b.c" → suggest "a b.c", NOT "a b c".
const result = runGsdTools(['a.b.c'], tmpDir);
assert.strictEqual(result.success, false, '"a.b.c" must fail');
assert.ok(
result.error.includes('a b.c'),
[
'suggestion for multi-dot unknown command must use first-dot split: "a b.c".',
'Got:', result.error,
].join('\n')
);
assert.ok(
!result.error.includes('a b c'),
[
'suggestion must NOT replace all dots ("a b c" is wrong — only first dot splits).',
'Got:', result.error,
].join('\n')
);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-3631-router-raw-flag.test.cjs — consolidation epic #1969 (B2 #1971)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-3631-router-raw-flag (consolidation epic #1969 B2 #1971)", () => {
'use strict';
/**
* Regression tests for #3631 — SDK dispatch path in family routers must
* forward the `--raw` flag through to `output()`.
*
* Before the fix, every `*-command-router.cjs` `sdkHandler` called
* `output(result.data)` without the second positional `raw` argument or the
* third positional `rawValue`. With `--raw` set, the SDK path therefore
* emitted JSON-stringified data ({"next":"2.1",...}) instead of the scalar
* the CJS path used to print (e.g. `2.1`).
*
* Both tests below exercise the live SDK path:
* 1. `phase next-decimal --raw <base>` must emit the next-decimal token.
* 2. `roadmap get-phase --raw <id>` must emit the phase's roadmap section.
*
* Per CONTRIBUTING.md: assertions are on structured (scalar) tokens, not
* substring grep against full JSON.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { execFileSync } = require('node:child_process');
const { cleanup } = require('./helpers.cjs');
const GSD_TOOLS = path.resolve(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
function run(args, cwd) {
try {
return {
ok: true,
stdout: execFileSync(process.execPath, [GSD_TOOLS, ...args], {
cwd,
encoding: 'utf-8',
timeout: 15000,
}),
};
} catch (e) {
return {
ok: false,
stdout: (e.stdout && e.stdout.toString()) || '',
stderr: (e.stderr && e.stderr.toString()) || '',
code: e.status,
};
}
}
function makeFixture() {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3631-'));
const planning = path.join(tmp, '.planning');
fs.mkdirSync(path.join(planning, 'phases'), { recursive: true });
fs.writeFileSync(
path.join(planning, 'ROADMAP.md'),
[
'# Project Roadmap',
'',
'## v1',
'',
'### Phase 1: First',
'',
'Body of phase 1.',
'',
'### Phase 2: Second',
'',
'Body of phase 2.',
'',
].join('\n')
);
// PROJECT.md anchors the planning root for callers that resolve it.
fs.writeFileSync(path.join(planning, 'PROJECT.md'), '# Test\n');
return tmp;
}
describe('bug #3631 — SDK family routers forward --raw to output()', () => {
test('phase next-decimal --raw emits the scalar next-decimal token (not JSON)', () => {
const tmp = makeFixture();
try {
const res = run(['phase', 'next-decimal', '--raw', '1'], tmp);
assert.ok(
res.ok,
`command must succeed; got code=${res.code} stderr=${res.stderr}`
);
const trimmed = res.stdout.trim();
// Scalar form — must be a phase id token like "1.1", not a JSON object.
assert.doesNotMatch(
trimmed,
/^\{/,
`--raw must not emit JSON; got: ${trimmed}`
);
assert.match(
trimmed,
/^0*\d+(?:\.\d+)?$/,
`--raw must emit a scalar phase id; got: ${trimmed}`
);
// SDK and CJS both normalize the base phase before computing the next-
// decimal token; CJS emits "1.1" while SDK normalizes "1"→"01" and emits
// "01.1". Both are valid scalar projections — assert on parity with the
// computed-next semantics rather than the exact padding form.
assert.ok(
trimmed === '1.1' || trimmed === '01.1',
`expected next-decimal of base "1" to be 1.1 or 01.1; got: ${trimmed}`
);
} finally {
cleanup(tmp);
}
});
test('roadmap get-phase --raw emits the phase section (not JSON)', () => {
const tmp = makeFixture();
try {
const res = run(['roadmap', 'get-phase', '--raw', '2'], tmp);
assert.ok(
res.ok,
`command must succeed; got code=${res.code} stderr=${res.stderr}`
);
const trimmed = res.stdout.trim();
assert.doesNotMatch(
trimmed,
/^\{/,
`--raw must not emit JSON; got: ${trimmed.slice(0, 80)}`
);
// Section text starts with the heading.
assert.match(
trimmed,
/Phase 2:\s*Second/,
`--raw must emit the section body containing the Phase 2 heading; got: ${trimmed.slice(0, 80)}`
);
} finally {
cleanup(tmp);
}
});
});
});
}

View File

@@ -352,3 +352,139 @@ describe('generate-claude-md skills section', () => {
assert.ok(skillsIdx < workflowIdx, 'Skills section should come before Workflow Enforcement');
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-3163-codex-agents-md.test.cjs — consolidation epic #1969 (B2 #1971)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-3163-codex-agents-md (consolidation epic #1969 B2 #1971)", () => {
'use strict';
/**
* Bug #3163: generate-claude-md should write to AGENTS.md on Codex runtime.
*
* When config.runtime === 'codex' (or GSD_RUNTIME=codex), the generate-claude-md
* handler must resolve the output path to AGENTS.md, not CLAUDE.md.
*/
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
describe('bug #3163: generate-claude-md uses AGENTS.md for Codex runtime', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
fs.writeFileSync(
path.join(tmpDir, '.planning', 'PROJECT.md'),
'# Test Project\n\nA Codex-hosted project.\n',
'utf-8'
);
});
afterEach(() => {
cleanup(tmpDir);
});
test('writes to AGENTS.md when config.runtime is codex and no --output given', () => {
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(
configPath,
JSON.stringify({ runtime: 'codex', claude_md_path: './CLAUDE.md' }),
'utf-8'
);
const result = runGsdTools('generate-claude-md', tmpDir, { HOME: tmpDir });
assert.ok(result.success, `Command failed: ${result.error}`);
const parsed = JSON.parse(result.output);
const realTmpDir = fs.realpathSync(tmpDir);
const expectedAgentsPath = path.join(realTmpDir, 'AGENTS.md');
// The returned path must be AGENTS.md, not CLAUDE.md
assert.strictEqual(parsed.claude_md_path, expectedAgentsPath,
`Expected output path to be AGENTS.md but got: ${parsed.claude_md_path}`
);
// AGENTS.md must exist on disk
assert.ok(fs.existsSync(expectedAgentsPath), 'AGENTS.md must exist after generation');
// CLAUDE.md must NOT be created
assert.ok(!fs.existsSync(path.join(realTmpDir, 'CLAUDE.md')), 'CLAUDE.md must not be created for Codex runtime');
});
test('writes to AGENTS.md when GSD_RUNTIME=codex env var is set (env takes precedence over config)', () => {
const configPath = path.join(tmpDir, '.planning', 'config.json');
// Config says runtime: claude but env overrides to codex
fs.writeFileSync(
configPath,
JSON.stringify({ runtime: 'claude', claude_md_path: './CLAUDE.md' }),
'utf-8'
);
const result = runGsdTools('generate-claude-md', tmpDir, { HOME: tmpDir, GSD_RUNTIME: 'codex' });
assert.ok(result.success, `Command failed: ${result.error}`);
const parsed = JSON.parse(result.output);
const realTmpDir = fs.realpathSync(tmpDir);
const expectedAgentsPath = path.join(realTmpDir, 'AGENTS.md');
assert.strictEqual(parsed.claude_md_path, expectedAgentsPath,
`Expected output path to be AGENTS.md but got: ${parsed.claude_md_path}`
);
assert.ok(fs.existsSync(expectedAgentsPath), 'AGENTS.md must exist after generation');
assert.ok(!fs.existsSync(path.join(realTmpDir, 'CLAUDE.md')), 'CLAUDE.md must not be created when GSD_RUNTIME=codex');
});
test('--output flag overrides runtime detection when explicitly provided', () => {
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(
configPath,
JSON.stringify({ runtime: 'codex', claude_md_path: './CLAUDE.md' }),
'utf-8'
);
// When --output is explicitly provided, it must be honoured regardless of runtime
const result = runGsdTools(
['generate-claude-md', '--output', 'EXPLICIT-OUTPUT.md'],
tmpDir,
{ HOME: tmpDir }
);
assert.ok(result.success, `Command failed: ${result.error}`);
const parsed = JSON.parse(result.output);
const realTmpDir = fs.realpathSync(tmpDir);
assert.strictEqual(
parsed.claude_md_path,
path.join(realTmpDir, 'EXPLICIT-OUTPUT.md'),
`Expected explicit --output to be honoured, got: ${parsed.claude_md_path}`
);
});
test('non-codex runtime still writes to CLAUDE.md', () => {
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(
configPath,
JSON.stringify({ runtime: 'claude', claude_md_path: './CLAUDE.md' }),
'utf-8'
);
const result = runGsdTools('generate-claude-md', tmpDir, { HOME: tmpDir });
assert.ok(result.success, `Command failed: ${result.error}`);
const parsed = JSON.parse(result.output);
const realTmpDir = fs.realpathSync(tmpDir);
assert.strictEqual(
parsed.claude_md_path,
path.join(realTmpDir, 'CLAUDE.md'),
`Expected CLAUDE.md for claude runtime, got: ${parsed.claude_md_path}`
);
assert.ok(fs.existsSync(path.join(realTmpDir, 'CLAUDE.md')), 'CLAUDE.md must exist for claude runtime');
assert.ok(!fs.existsSync(path.join(realTmpDir, 'AGENTS.md')), 'AGENTS.md must not be created for claude runtime');
});
});
});
}

View File

@@ -949,3 +949,855 @@ describe('CommandRoutingHub — exitReason? field on InvalidArgs (#1644 / amendm
`Extended InvalidArgs must not be coerced to HandlerFailure; got kind: ${result.kind}`);
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-167-query-meta-command.test.cjs — consolidation epic #1969 (B2 #1971)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-167-query-meta-command (consolidation epic #1969 B2 #1971)", () => {
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { runGsdTools } = require('./helpers.cjs');
test('bug #167: query meta-command prefixes direct gsd-tools calls', () => {
const direct = runGsdTools(['init.progress']);
assert.equal(direct.success, true, `init.progress failed: ${direct.error || direct.output}`);
const meta = runGsdTools(['query', 'init.progress']);
assert.equal(meta.success, true, `query init.progress failed: ${meta.error || meta.output}`);
assert.deepEqual(
JSON.parse(meta.output),
JSON.parse(direct.output),
'query-prefixed and direct invocations should return identical init.progress payloads'
);
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-1818-unknown-flags.test.cjs — consolidation epic #1969 (B2 #1971)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-1818-unknown-flags (consolidation epic #1969 B2 #1971)", () => {
/**
* Regression test for bug #1818, updated for #3019.
*
* Original #1818 invariant: gsd-tools must NOT silently ignore --help/-h
* and proceed with a destructive command — that turned AI-agent
* hallucinations into accidental data loss (e.g. `phases clear --help`
* deleting phase dirs because the flag was dropped).
*
* #3019 update: the same destructive-protection invariant still holds,
* but the response shape changed. Previously --help → non-zero error
* exit. Now --help → render top-level usage and exit 0 WITHOUT running
* the command. Both shapes satisfy the original invariant ("the
* destructive command did not execute"); the new shape also restores
* subcommand discoverability for `gsd-sdk query <subcommand> --help`.
*
* The tests therefore assert two things:
* 1. The destructive command did NOT run (anti-hallucination invariant).
* 2. The output contains the top-level usage (#3019 discoverability).
*
* --version remains rejected — it's never a valid gsd-tools flag and has
* no discovery use-case.
*/
'use strict';
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { runGsdTools, createTempProject, cleanup, isUsageOutput } = require('./helpers.cjs');
describe('unknown flag guard (bug #1818, updated for #3019)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
// ── --help renders usage and does NOT run the destructive command ────────
test('phases clear --help renders usage and does NOT clear phase dirs', () => {
// Create a sentinel phase dir so we can assert it survives.
const phaseDir = path.join(tmpDir, '.planning', 'phases', 'phase-99');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, 'PLAN.md'), 'sentinel');
const result = runGsdTools(['phases', 'clear', '--help'], tmpDir);
assert.strictEqual(result.success, true, 'help renders, no error exit');
assert.ok(isUsageOutput(result.output), `expected top-level usage, got: ${result.output}`);
// Anti-hallucination invariant: the destructive command did NOT run.
assert.ok(fs.existsSync(phaseDir), 'phase dir must survive — clear must not have executed');
assert.ok(fs.existsSync(path.join(phaseDir, 'PLAN.md')));
});
test('generate-slug hello --help renders usage and does NOT emit a slug', () => {
const ok = runGsdTools(['generate-slug', 'hello'], tmpDir);
assert.strictEqual(ok.success, true, 'control: generate-slug works without --help');
// The control output is just the slug; the help output is the usage.
const slugOut = ok.output;
assert.ok(slugOut && !isUsageOutput(slugOut), `control should not be usage: ${slugOut}`);
const result = runGsdTools(['generate-slug', 'hello', '--help'], tmpDir);
assert.strictEqual(result.success, true);
assert.ok(isUsageOutput(result.output), 'help renders top-level usage');
assert.notEqual(result.output, slugOut, 'help output must differ from the slug — generate-slug must not have run');
});
test('phase complete --help renders usage and does NOT mark a phase complete', () => {
const result = runGsdTools(['phase', 'complete', '--help'], tmpDir);
assert.strictEqual(result.success, true);
assert.ok(isUsageOutput(result.output));
// success:true + isUsageOutput is sufficient: if the destructive path
// had executed it would have emitted a phase-resolution error to stderr
// (success:false), not the usage to stdout (success:true).
});
test('state load --help renders usage', () => {
const result = runGsdTools(['state', 'load', '--help'], tmpDir);
assert.strictEqual(result.success, true);
assert.ok(isUsageOutput(result.output));
});
// ── -h shorthand: same shape ─────────────────────────────────────────────
test('phases clear -h renders usage and does NOT clear phase dirs', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', 'phase-42');
fs.mkdirSync(phaseDir, { recursive: true });
const result = runGsdTools(['phases', 'clear', '-h'], tmpDir);
assert.strictEqual(result.success, true);
assert.ok(isUsageOutput(result.output));
assert.ok(fs.existsSync(phaseDir), 'phase dir must survive');
});
test('generate-slug hello -h renders usage', () => {
const result = runGsdTools(['generate-slug', 'hello', '-h'], tmpDir);
assert.strictEqual(result.success, true);
assert.ok(isUsageOutput(result.output));
});
// ── --version is still rejected — no discovery use-case ──────────────────
test('generate-slug hello --version is rejected', () => {
const result = runGsdTools(['generate-slug', 'hello', '--version'], tmpDir);
assert.strictEqual(result.success, false);
assert.match(result.error, /--version/);
});
// ── current-timestamp --help: same as the others ─────────────────────────
test('current-timestamp --help renders usage', () => {
const result = runGsdTools(['current-timestamp', '--help'], tmpDir);
assert.strictEqual(result.success, true);
assert.ok(isUsageOutput(result.output));
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/feat-3255-json-errors-mode.test.cjs — consolidation epic #1969 (B2 #1971)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:feat-3255-json-errors-mode (consolidation epic #1969 B2 #1971)", () => {
/**
* Tests for the --json-errors mode added in #3255.
*
* When gsd-tools is invoked with --json-errors, all error() calls emit a
* structured JSON object to stderr:
*
* { ok: false, reason: "<error_code>", message: "<human text>" }
*
* This lets tests assert on typed reason codes instead of grepping free-form
* stderr text. All assertions below parse the captured stderr via JSON.parse
* and inspect typed fields — never result.error.includes() (#2974 / k001).
*
* Covered error paths (representative set, each exercises a different branch):
* 1. Unknown top-level command → reason: "sdk_unknown_command"
* 2. Unknown dotted command → reason: "sdk_unknown_command"
* 3. Missing required argument → reason: "usage" (--pick without value)
* 4. Config key not found → reason: "config_key_not_found"
* 5. Unknown subcommand → reason: "sdk_unknown_command"
* 6. GSD_JSON_ERRORS=1 env var → same structured output without --flag
* 7. Successful command unaffected
* 8. Error object shape is stable ({ok, reason, message})
* 9. Single error line per invocation
* 10. Unknown flag → reason: "usage"
*/
'use strict';
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
// Helper: run gsd-tools with --json-errors and parse the structured stderr.
// Returns the parsed object, or throws if stderr is not valid JSON.
function runJsonErrors(args, tmpDir, env = {}) {
const allArgs = ['--json-errors', ...args];
const result = runGsdTools(allArgs, tmpDir, env);
// Must have failed
assert.strictEqual(result.success, false,
`Expected failure with --json-errors for args: ${args.join(' ')}\nstdout: ${result.output}\nstderr: ${result.error}`);
let parsed;
try {
parsed = JSON.parse(result.error);
} catch (e) {
throw new Error(
`--json-errors must emit valid JSON on stderr.\n` +
`Args: ${args.join(' ')}\n` +
`stderr: ${result.error}\n` +
`parse error: ${e.message}`
);
}
return parsed;
}
describe('feat #3255: --json-errors mode emits structured error objects', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
// ── 1. Unknown top-level command ─────────────────────────────────────────
test('unknown top-level command emits { ok: false, reason: "sdk_unknown_command" }', () => {
const parsed = runJsonErrors(['totally-unknown-command-xyzzy'], tmpDir);
assert.strictEqual(parsed.ok, false,
'error object must have ok: false');
assert.strictEqual(parsed.reason, 'sdk_unknown_command',
`reason must be "sdk_unknown_command", got: ${parsed.reason}`);
assert.ok(typeof parsed.message === 'string' && parsed.message.length > 0,
'message must be a non-empty string');
});
// ── 2. Unknown dotted command ────────────────────────────────────────────
test('unknown dotted command (foo.bar) emits { ok: false, reason: "sdk_unknown_command" }', () => {
const parsed = runJsonErrors(['foo.bar'], tmpDir);
assert.strictEqual(parsed.ok, false,
'error object must have ok: false');
assert.strictEqual(parsed.reason, 'sdk_unknown_command',
`dotted unknown command reason must be "sdk_unknown_command", got: ${parsed.reason}`);
assert.ok(typeof parsed.message === 'string' && parsed.message.length > 0,
'message must be a non-empty string');
});
// ── 3. Missing --pick value ───────────────────────────────────────────────
test('--pick without value emits { ok: false, reason: "usage" }', () => {
const parsed = runJsonErrors(['generate-slug', 'test-text', '--pick'], tmpDir);
assert.strictEqual(parsed.ok, false,
'error object must have ok: false');
assert.strictEqual(parsed.reason, 'usage',
`missing --pick value reason must be "usage", got: ${parsed.reason}`);
assert.ok(typeof parsed.message === 'string' && parsed.message.length > 0,
'message must be a non-empty string');
});
// ── 4. Config key not found ───────────────────────────────────────────────
test('config-get for absent key emits { ok: false, reason: "config_key_not_found" }', () => {
// Initialise config.json first so we reach the "key not found" branch
// rather than the "no config.json" branch.
runGsdTools(['config-ensure-section'], tmpDir);
const parsed = runJsonErrors(['config-get', 'nonexistent_config_key_xyzzy'], tmpDir);
assert.strictEqual(parsed.ok, false,
'error object must have ok: false');
assert.strictEqual(parsed.reason, 'config_key_not_found',
`reason must be "config_key_not_found", got: ${parsed.reason}`);
assert.ok(typeof parsed.message === 'string' && parsed.message.length > 0,
'message must be a non-empty string');
});
// ── 5. Unknown subcommand within a domain ────────────────────────────────
test('unknown intel subcommand emits { ok: false, reason: "sdk_unknown_command" }', () => {
const parsed = runJsonErrors(['intel', 'bogus-subcommand-xyzzy'], tmpDir);
assert.strictEqual(parsed.ok, false,
'error object must have ok: false');
assert.strictEqual(parsed.reason, 'sdk_unknown_command',
`unknown subcommand reason must be "sdk_unknown_command", got: ${parsed.reason}`);
assert.ok(typeof parsed.message === 'string' && parsed.message.length > 0,
'message must be a non-empty string');
});
// ── 6. GSD_JSON_ERRORS=1 env var activates structured mode ───────────────
test('GSD_JSON_ERRORS=1 env var produces same structured error as --json-errors flag', () => {
// Run with env var instead of --json-errors flag
const result = runGsdTools(
['totally-unknown-command-xyzzy'],
tmpDir,
{ GSD_JSON_ERRORS: '1' }
);
assert.strictEqual(result.success, false,
'command must fail');
let parsed;
try {
parsed = JSON.parse(result.error);
} catch (e) {
throw new Error(
`GSD_JSON_ERRORS=1 must emit valid JSON on stderr.\n` +
`stderr: ${result.error}\n` +
`parse error: ${e.message}`
);
}
assert.strictEqual(parsed.ok, false,
'error object must have ok: false');
assert.strictEqual(parsed.reason, 'sdk_unknown_command',
`reason must be "sdk_unknown_command", got: ${parsed.reason}`);
});
// ── 7. Successful commands are unaffected by --json-errors ───────────────
test('successful command with --json-errors flag still succeeds normally', () => {
const result = runGsdTools(
['--json-errors', 'generate-slug', 'hello-world'],
tmpDir
);
assert.strictEqual(result.success, true,
`Successful command must not be broken by --json-errors flag.\nstderr: ${result.error}`);
assert.ok(result.output.length > 0,
'stdout must be non-empty for successful generate-slug');
});
// ── 8. Error object shape is stable (no extra top-level keys) ────────────
test('error object contains exactly {ok, reason, message} — no extra keys', () => {
const parsed = runJsonErrors(['totally-unknown-command-xyzzy'], tmpDir);
const keys = Object.keys(parsed).sort();
assert.deepStrictEqual(keys, ['message', 'ok', 'reason'],
`error object must have exactly {ok, reason, message}. Got keys: ${keys.join(', ')}`);
});
// ── 9. Multiple errors in one session: only the first error is emitted ───
test('only one error JSON line is emitted per invocation (process exits on first error)', () => {
const result = runGsdTools(
['--json-errors', 'totally-unknown-command-xyzzy'],
tmpDir
);
assert.strictEqual(result.success, false, 'must fail');
const lines = result.error.trim().split('\n').filter(l => l.length > 0);
assert.strictEqual(lines.length, 1,
`stderr must contain exactly one JSON line, got ${lines.length}:\n${result.error}`);
// Also verify the single line is valid JSON
const parsed = JSON.parse(lines[0]);
assert.strictEqual(parsed.ok, false);
});
// ── 10. Unknown flag emits { ok: false, reason: "usage" } ────────────────
test('unknown version flag emits { ok: false, reason: "usage" }', () => {
const parsed = runJsonErrors(['--version', 'generate-slug', 'x'], tmpDir);
assert.strictEqual(parsed.ok, false, 'error object must have ok: false');
assert.strictEqual(parsed.reason, 'usage',
`--version flag reason must be "usage", got: ${parsed.reason}`);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/feat-3310-followup-typed-codes.test.cjs — consolidation epic #1969 (B2 #1971)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:feat-3310-followup-typed-codes (consolidation epic #1969 B2 #1971)", () => {
/**
* Follow-up tests for #3310: every remaining `error()` call at a subcommand
* boundary or usage check in `gsd-tools.cjs` carries a typed `ERROR_REASON`.
*
* #3304 wired four representative paths (unknown top-level command, unknown
* intel subcommand, missing --pick value, --version flag). The rest fell
* through to `ERROR_REASON.UNKNOWN`. This file locks the post-#3310 contract:
*
* - Every "Unknown <subsystem> subcommand" emits reason: "sdk_unknown_command".
* - Every "Usage: ..." / missing-required-arg path emits reason: "usage".
*
* All assertions parse stderr via JSON.parse — never `.includes()` — per the
* #2974 / CONTRIBUTING.md "Prohibited: Raw Text Matching" rule.
*/
'use strict';
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
// Run gsd-tools with GSD_JSON_ERRORS=1 (env-var activation, exercises the
// path #3304 added alongside the --json-errors flag) and parse the
// structured stderr. Returns the parsed object; throws if stderr is not JSON.
function runJsonErrors(args, tmpDir, env = {}) {
const result = runGsdTools(args, tmpDir, { ...env, GSD_JSON_ERRORS: '1' });
assert.strictEqual(result.success, false,
`Expected failure with GSD_JSON_ERRORS=1 for args: ${args.join(' ')}\n` +
`stdout: ${result.output}\nstderr: ${result.error}`);
let parsed;
try {
parsed = JSON.parse(result.error);
} catch (e) {
throw new Error(
`GSD_JSON_ERRORS=1 must emit valid JSON on stderr.\n` +
`Args: ${args.join(' ')}\nstderr: ${result.error}\nparse error: ${e.message}`
);
}
return parsed;
}
// Assert the typed-IR contract: object shape + reason. Keeps the per-test
// boilerplate minimal so each error-path test reads as a single fact.
function assertTypedError(parsed, expectedReason, label) {
assert.strictEqual(parsed.ok, false,
`${label}: error object must have ok: false`);
assert.strictEqual(parsed.reason, expectedReason,
`${label}: reason must be "${expectedReason}", got: ${parsed.reason}`);
assert.ok(typeof parsed.message === 'string' && parsed.message.length > 0,
`${label}: message must be a non-empty string`);
}
describe('feat #3310: typed ERROR_REASON codes on remaining error paths', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
// ── Unknown <subsystem> subcommand → SDK_UNKNOWN_COMMAND ────────────────
// Each of these used to fall through to reason: "unknown" before #3310.
test('unknown template subcommand → sdk_unknown_command', () => {
const parsed = runJsonErrors(['template', 'bogus-subcommand-xyzzy'], tmpDir);
assertTypedError(parsed, 'sdk_unknown_command', 'template');
});
test('unknown frontmatter subcommand → sdk_unknown_command', () => {
// frontmatter expects subcommand at args[1] and file at args[2]; pass a
// bogus subcommand with a placeholder file so we definitely reach the
// unknown-subcommand branch, not an earlier validation.
const parsed = runJsonErrors(
['frontmatter', 'bogus-subcommand-xyzzy', 'placeholder.md'],
tmpDir
);
assertTypedError(parsed, 'sdk_unknown_command', 'frontmatter');
});
test('unknown requirements subcommand → sdk_unknown_command', () => {
const parsed = runJsonErrors(['requirements', 'bogus-subcommand-xyzzy'], tmpDir);
assertTypedError(parsed, 'sdk_unknown_command', 'requirements');
});
test('unknown milestone subcommand → sdk_unknown_command', () => {
const parsed = runJsonErrors(['milestone', 'bogus-subcommand-xyzzy'], tmpDir);
assertTypedError(parsed, 'sdk_unknown_command', 'milestone');
});
test('unknown uat subcommand → sdk_unknown_command', () => {
const parsed = runJsonErrors(['uat', 'bogus-subcommand-xyzzy'], tmpDir);
assertTypedError(parsed, 'sdk_unknown_command', 'uat');
});
test('unknown todo subcommand → sdk_unknown_command', () => {
const parsed = runJsonErrors(['todo', 'bogus-subcommand-xyzzy'], tmpDir);
assertTypedError(parsed, 'sdk_unknown_command', 'todo');
});
test('unknown workstream subcommand → sdk_unknown_command', () => {
const parsed = runJsonErrors(['workstream', 'bogus-subcommand-xyzzy'], tmpDir);
assertTypedError(parsed, 'sdk_unknown_command', 'workstream');
});
test('unknown graphify subcommand → sdk_unknown_command', () => {
const parsed = runJsonErrors(['graphify', 'bogus-subcommand-xyzzy'], tmpDir);
assertTypedError(parsed, 'sdk_unknown_command', 'graphify');
});
test('unknown learnings subcommand → sdk_unknown_command', () => {
const parsed = runJsonErrors(['learnings', 'bogus-subcommand-xyzzy'], tmpDir);
assertTypedError(parsed, 'sdk_unknown_command', 'learnings');
});
// ── Missing required positional/flag values → USAGE ─────────────────────
// These previously emitted reason: "unknown" because the second argument
// to error() was absent.
test('missing --cwd value → usage', () => {
// The --cwd flag is consumed before the command dispatcher; passing it
// bare with no following value triggers the usage error at L253/L258.
const parsed = runJsonErrors(['--cwd'], tmpDir);
assertTypedError(parsed, 'usage', '--cwd missing value');
});
test('invalid --cwd directory → usage', () => {
// --cwd <nonexistent-path> hits the existsSync / isDirectory check at L264.
const parsed = runJsonErrors(
['--cwd', '/this/path/should/not/exist/anywhere/xyzzy', 'state', 'load'],
tmpDir
);
assertTypedError(parsed, 'usage', 'invalid --cwd directory');
});
test('intel query missing term → usage', () => {
const parsed = runJsonErrors(['intel', 'query'], tmpDir);
assertTypedError(parsed, 'usage', 'intel query missing term');
});
test('intel patch-meta missing file path → usage', () => {
const parsed = runJsonErrors(['intel', 'patch-meta'], tmpDir);
assertTypedError(parsed, 'usage', 'intel patch-meta missing file');
});
test('intel extract-exports missing file path → usage', () => {
const parsed = runJsonErrors(['intel', 'extract-exports'], tmpDir);
assertTypedError(parsed, 'usage', 'intel extract-exports missing file');
});
test('graphify query missing term → usage', () => {
const parsed = runJsonErrors(['graphify', 'query'], tmpDir);
assertTypedError(parsed, 'usage', 'graphify query missing term');
});
test('learnings query missing --tag → usage', () => {
const parsed = runJsonErrors(['learnings', 'query'], tmpDir);
assertTypedError(parsed, 'usage', 'learnings query missing --tag');
});
test('learnings prune missing --older-than → usage', () => {
const parsed = runJsonErrors(['learnings', 'prune'], tmpDir);
assertTypedError(parsed, 'usage', 'learnings prune missing --older-than');
});
test('learnings delete missing id → usage', () => {
const parsed = runJsonErrors(['learnings', 'delete'], tmpDir);
assertTypedError(parsed, 'usage', 'learnings delete missing id');
});
test('extract-messages missing project arg → usage', () => {
// L877 — args[1] is undefined or starts with '--'.
const parsed = runJsonErrors(['extract-messages'], tmpDir);
assertTypedError(parsed, 'usage', 'extract-messages missing project');
});
test('write-profile missing --input → usage', () => {
const parsed = runJsonErrors(['write-profile'], tmpDir);
assertTypedError(parsed, 'usage', 'write-profile missing --input');
});
test('detect-custom-files missing --config-dir → usage', () => {
const parsed = runJsonErrors(['detect-custom-files'], tmpDir);
assertTypedError(parsed, 'usage', 'detect-custom-files missing --config-dir');
});
test('detect-custom-files invalid --config-dir → usage', () => {
const parsed = runJsonErrors(
['detect-custom-files', '--config-dir', '/nonexistent/path/xyzzy'],
tmpDir
);
assertTypedError(parsed, 'usage', 'detect-custom-files invalid --config-dir');
});
// ── Shape regression guard: every newly-typed path emits the canonical
// {ok, reason, message} object — no leakage of reason: "unknown". ────
test('every remaining typed path emits the canonical {ok, reason, message} shape', () => {
const probes = [
['template', 'bogus'],
['frontmatter', 'bogus', 'placeholder.md'],
['requirements', 'bogus'],
['milestone', 'bogus'],
['uat', 'bogus'],
['todo', 'bogus'],
['workstream', 'bogus'],
['graphify', 'bogus'],
['learnings', 'bogus'],
['intel', 'query'],
['extract-messages'],
['write-profile'],
['detect-custom-files'],
];
for (const args of probes) {
const parsed = runJsonErrors(args, tmpDir);
const keys = Object.keys(parsed).sort();
assert.deepStrictEqual(keys, ['message', 'ok', 'reason'],
`args ${args.join(' ')}: keys must be exactly {ok,reason,message}, got ${keys.join(',')}`);
assert.notStrictEqual(parsed.reason, 'unknown',
`args ${args.join(' ')}: reason must be a typed code, not the fallback "unknown"`);
}
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-853-bg-dispatch-runtime-gating.test.cjs — consolidation epic #1969 (B2 #1971)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-853-bg-dispatch-runtime-gating (consolidation epic #1969 B2 #1971)", () => {
'use strict';
/**
* Regression guard — bug(#853): /gsd-manager and /gsd-autonomous --interactive
* silently skipped worktree isolation + independent verification because they
* dispatched Plan/Execute via Agent(run_in_background=true). On Claude Code a
* backgrounded agent has no Agent/Task tool, so it cannot spawn the nested
* subagents (worktree executors, plan-checker, verifier). The workflows must
* now resolve dispatch capability from the registry (#1708) and run inline
* everywhere except runtimes where dispatch.background && dispatch.backgroundDispatch
* are both true (currently: codex, cursor).
*
* Phase B (#1708): the prose `RUNTIME === 'codex'` rule is graduated to a typed
* `gsd_run query dispatch-should-flatten` query backed by shouldFlattenDispatch()
* from host-integration.cjs and the documentation-sourced capability registry.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createTempProject, cleanup: cleanupDir, runGsdTools } = require('./helpers.cjs');
const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows');
// allow-test-rule: source-text-is-the-product (see #1708)
const MANAGER = fs.readFileSync(path.join(WORKFLOWS_DIR, 'manager.md'), 'utf8');
// allow-test-rule: source-text-is-the-product (see #1708)
const AUTONOMOUS = fs.readFileSync(path.join(WORKFLOWS_DIR, 'autonomous.md'), 'utf8');
describe('bug-853 — manager/autonomous gate background dispatch by runtime', () => {
test('manager.md resolves dispatch-should-flatten before dispatching plan/execute', () => {
// Two dispatch sites (plan + execute), each must use dispatch-should-flatten.
// allow-test-rule: source-text-is-the-product (see #1708)
const matches = MANAGER.match(/dispatch-should-flatten/g) || [];
assert.ok(matches.length >= 2, 'manager.md must use dispatch-should-flatten for both plan and execute dispatch');
});
test('manager.md documents why most runtimes cannot background-dispatch', () => {
// Accept both old singular form (backgrounded agent has no) and new plural form (backgrounded agents have no)
// allow-test-rule: source-text-is-the-product (see #1708)
assert.match(MANAGER, /backgrounded agents? ha(?:s|ve) no `Agent`\/`Task` tool/);
});
test('manager.md gates background dispatch on FLATTEN=false and runs plan/execute inline otherwise', () => {
// Background path uses FLATTEN is false
// allow-test-rule: source-text-is-the-product (see #1708)
assert.match(MANAGER, /If `FLATTEN` is `false`[\s\S]{0,400}?run_in_background=true/);
// Inline is the default/else branch for plan — anchored on FLATTEN=true language (not runtime name)
assert.match(
MANAGER,
/Otherwise[\s\S]{0,100}?`FLATTEN`[\s\S]{0,400}?Skill\(skill="gsd-plan-phase"/,
);
// Inline is the default/else branch for execute — anchored on FLATTEN=true language (not runtime name)
assert.match(
MANAGER,
/Otherwise[\s\S]{0,100}?`FLATTEN`[\s\S]{0,400}?Skill\(skill="gsd-execute-phase"/,
);
});
test('manager.md compound action preamble uses FLATTEN language (not hardcoded runtime names)', () => {
// allow-test-rule: source-text-is-the-product (see #1708)
const compoundActionSection = MANAGER.match(
/### Compound Action \(background \+ inline\)[\s\S]*?Inline verification:/,
);
assert.ok(compoundActionSection, 'manager.md must document compound action runtime dispatch');
// Must gate on FLATTEN being false (not runtime name)
assert.match(
compoundActionSection[0],
/If `FLATTEN` is `false`[\s\S]{0,400}?Spawn all background agents first[\s\S]{0,300}?plan\/execute/,
);
// Otherwise / inline branch must reference FLATTEN being true
assert.match(
compoundActionSection[0],
/Otherwise[\s\S]{0,260}?`FLATTEN`[\s\S]{0,260}?`true`[\s\S]{0,260}?inline/,
);
// Must NOT still hardcode "On Codex:" in this section
assert.doesNotMatch(
compoundActionSection[0],
/\*\*On Codex:\*\*/,
);
// Must NOT still hardcode "On Claude Code or any other non-Codex runtime:"
assert.doesNotMatch(
compoundActionSection[0],
/On Claude Code or any other non-Codex runtime:/,
);
});
test('autonomous.md gates interactive background dispatch using dispatch-should-flatten', () => {
// Two dispatch sites (3b plan + 3c execute), each must use dispatch-should-flatten.
// allow-test-rule: source-text-is-the-product (see #1708)
const autoFlattenMatches = AUTONOMOUS.match(/dispatch-should-flatten/g) || [];
assert.ok(autoFlattenMatches.length >= 2, 'autonomous.md must use dispatch-should-flatten in both 3b (plan) and 3c (execute) interactive branches');
// Accept both old singular form (backgrounded agent has no) and new plural form (backgrounded agents have no)
assert.match(AUTONOMOUS, /backgrounded agents? ha(?:s|ve) no `Agent`\/`Task` tool/);
});
test('autonomous.md gates interactive background dispatch on FLATTEN=false; runs plan/execute inline otherwise', () => {
// Background block: run_in_background=true appears within the FLATTEN=false branch and gsd-plan-phase is nearby
// allow-test-rule: source-text-is-the-product (see #1708)
assert.match(AUTONOMOUS, /If `FLATTEN` is `false`[\s\S]{0,1200}?run_in_background=true[\s\S]{0,600}?gsd-plan-phase/);
// Background block: run_in_background=true appears within the FLATTEN=false branch and gsd-execute-phase is nearby
assert.match(AUTONOMOUS, /If `FLATTEN` is `false`[\s\S]{0,3000}?run_in_background=true[\s\S]{0,200}?gsd-execute-phase/);
// Inline is the otherwise/else branch for plan — anchored on FLATTEN=true language (not runtime name)
assert.match(
AUTONOMOUS,
/Otherwise[\s\S]{0,100}?`FLATTEN`[\s\S]{0,400}?Skill\(skill="gsd-plan-phase"/,
);
// Inline is the otherwise/else branch for execute — anchored on FLATTEN=true language (not runtime name)
assert.match(
AUTONOMOUS,
/Otherwise[\s\S]{0,100}?`FLATTEN`[\s\S]{0,400}?Skill\(skill="gsd-execute-phase"/,
);
});
});
describe('dispatch-should-flatten query — behavioral', () => {
// #853 / #1708: The typed query replaces prose-level RUNTIME===codex checks.
// shouldFlattenDispatch returns false only when both dispatch.background AND
// dispatch.backgroundDispatch are true in the capability registry.
//
// Registry values (from host-integration-capability-matrix.md):
// codex: background=true, backgroundDispatch=true → shouldFlatten=false (may background)
// claude: background=true, backgroundDispatch=false → shouldFlatten=true (must inline)
// cursor: background=true, backgroundDispatch=true → shouldFlatten=false (may background)
// unknown: no entry → fail-closed → shouldFlatten=true (must inline)
test('runtime=codex → shouldFlatten=false (background dispatch safe)', () => {
const tmpDir = createTempProject();
try {
const result = runGsdTools(['query', 'dispatch-should-flatten', '--raw'], tmpDir, {
GSD_RUNTIME: 'codex',
});
assert.ok(result.success, `Expected success, got error: ${result.error}`);
assert.strictEqual(result.output, 'false', `codex should return false (may background), got: ${result.output}`);
} finally {
cleanupDir(tmpDir);
}
});
test('runtime=claude → shouldFlatten=true (must inline)', () => {
const tmpDir = createTempProject();
try {
const result = runGsdTools(['query', 'dispatch-should-flatten', '--raw'], tmpDir, {
GSD_RUNTIME: 'claude',
});
assert.ok(result.success, `Expected success, got error: ${result.error}`);
assert.strictEqual(result.output, 'true', `claude should return true (must inline), got: ${result.output}`);
} finally {
cleanupDir(tmpDir);
}
});
test('runtime=cursor → shouldFlatten=false (background dispatch safe)', () => {
const tmpDir = createTempProject();
try {
const result = runGsdTools(['query', 'dispatch-should-flatten', '--raw'], tmpDir, {
GSD_RUNTIME: 'cursor',
});
assert.ok(result.success, `Expected success, got error: ${result.error}`);
assert.strictEqual(result.output, 'false', `cursor should return false (may background), got: ${result.output}`);
} finally {
cleanupDir(tmpDir);
}
});
test('unknown runtime → shouldFlatten=true (fail-closed → must inline)', () => {
// An unknown runtime has no registry entry → dispatch is null → fail-closed to true.
const tmpDir = createTempProject();
try {
const result = runGsdTools(['query', 'dispatch-should-flatten', '--raw'], tmpDir, {
GSD_RUNTIME: 'unknown-runtime-xyz',
});
// The query must succeed (exit 0) even for unknown runtimes — fail-closed not crash-closed.
assert.ok(result.success, `Expected success (fail-closed), got error: ${result.error}`);
assert.strictEqual(result.output, 'true', `unknown runtime should return true (fail-closed), got: ${result.output}`);
} finally {
cleanupDir(tmpDir);
}
});
test('--json flag returns structured { runtime, shouldFlatten, dispatch }', () => {
const tmpDir = createTempProject();
try {
const result = runGsdTools(['query', 'dispatch-should-flatten', '--json'], tmpDir, {
GSD_RUNTIME: 'codex',
});
assert.ok(result.success, `Expected success, got error: ${result.error}`);
let parsed;
try {
parsed = JSON.parse(result.output);
} catch {
assert.fail(`Expected valid JSON output, got: ${result.output}`);
}
assert.strictEqual(parsed.runtime, 'codex');
assert.strictEqual(parsed.shouldFlatten, false);
assert.ok(parsed.dispatch !== null && typeof parsed.dispatch === 'object', 'dispatch should be an object');
assert.strictEqual(parsed.dispatch.backgroundDispatch, true);
} finally {
cleanupDir(tmpDir);
}
});
test('config.runtime takes precedence when GSD_RUNTIME not set', () => {
// GSD_RUNTIME > config.runtime > 'claude'
// Write config.json with runtime=codex; no GSD_RUNTIME override.
const tmpDir = createTempProject();
try {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ runtime: 'codex' }),
'utf-8',
);
// Override GSD_RUNTIME to '' (empty string) so any ambient value is cleared.
// resolveRuntimeNameFromCandidates treats empty string as absent (normalizes
// to '' which is falsy → skipped → falls through to config.runtime=codex).
// This is the only way to suppress an ambient GSD_RUNTIME since runGsdTools
// merges { ...process.env, ...TEST_ENV_BASE, ...env } — passing '' as the
// override overwrites the ambient value at the correct merge position.
const result = runGsdTools(['query', 'dispatch-should-flatten', '--raw'], tmpDir, {
GSD_RUNTIME: '',
});
// config.runtime=codex with GSD_RUNTIME cleared → codex backgrounds → shouldFlatten=false
assert.ok(result.success, `Expected success, got error: ${result.error}`);
assert.strictEqual(result.output, 'false', `config.runtime=codex (GSD_RUNTIME cleared) should return false (may background), got: ${result.output}`);
} finally {
cleanupDir(tmpDir);
}
});
});
});
}

View File

@@ -1462,3 +1462,585 @@ describe('config-set workflow.test_command (#1216)', () => {
assert.strictEqual(config.workflow?.build_command, 'npm run build', 'value must be persisted');
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-2601-inherit-model-profile.test.cjs — consolidation epic #1969 (B2 #1971)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-2601-inherit-model-profile (consolidation epic #1969 B2 #1971)", () => {
'use strict';
/**
* Regression tests for bug #2601
*
* `config-set-model-profile inherit` (and `config-set model_profile inherit`)
* was rejected by the validator even though the runtime accepts 'inherit' as a
* valid model_profile value meaning "inherit from parent configuration".
*
* Root cause: VALID_PROFILES in model-profiles.cjs is derived from
* Object.keys(MODEL_PROFILES['gsd-planner']), which does not include 'inherit'.
* cmdConfigSetModelProfile() rejects any value not in VALID_PROFILES.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
describe('bug #2601: config-set-model-profile accepts inherit', () => {
test('config-set-model-profile inherit succeeds', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(['config-set-model-profile', 'inherit'], tmpDir);
assert.ok(result.success, `should accept inherit: ${result.error}`);
});
test('config-set model_profile inherit succeeds', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(['config-set', 'model_profile', 'inherit'], tmpDir);
assert.ok(result.success, `config-set model_profile inherit should succeed: ${result.error}`);
});
test('config-set-model-profile inherit writes inherit to config', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
runGsdTools(['config-set-model-profile', 'inherit'], tmpDir);
const getResult = runGsdTools(['config-get', 'model_profile'], tmpDir);
assert.ok(getResult.success, `config-get should succeed: ${getResult.error}`);
assert.strictEqual(JSON.parse(getResult.output), 'inherit');
});
test('config-set-model-profile still rejects truly invalid profiles', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(['config-set-model-profile', 'not-a-real-profile'], tmpDir);
assert.ok(!result.success, 'should reject invalid profiles');
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-3197-gsd-tools-config-whitelist.test.cjs — consolidation epic #1969 (B2 #1971)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-3197-gsd-tools-config-whitelist (consolidation epic #1969 B2 #1971)", () => {
'use strict';
/**
* Regression test for #3197 — gsd-tools config-set rejects workflow._auto_chain_active.
*
* Root cause: RUNTIME_STATE_KEYS was added to sdk/src/query/config-schema.ts in #3162
* but not to gsd-core/bin/lib/config-schema.cjs, so gsd-tools.cjs users still hit
* "Unknown config key" when setting workflow._auto_chain_active.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
describe('#3197 — gsd-tools.cjs config-set workflow._auto_chain_active', () => {
test('config-set workflow._auto_chain_active true succeeds via gsd-tools.cjs (CJS path)', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(['config-set', 'workflow._auto_chain_active', 'true'], tmpDir);
assert.ok(
result.success,
`config-set workflow._auto_chain_active true should succeed, got:\nstdout: ${result.output}\nstderr: ${result.error}`
);
});
test('config-set workflow._auto_chain_active true writes value to config.json', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
runGsdTools(['config-set', 'workflow._auto_chain_active', 'true'], tmpDir);
const configPath = path.join(tmpDir, '.planning', 'config.json');
assert.ok(fs.existsSync(configPath), '.planning/config.json must exist after config-set');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
assert.ok(
config.workflow !== undefined && config.workflow._auto_chain_active === true,
`Expected workflow._auto_chain_active: true in config.json, got: ${JSON.stringify(config)}`
);
});
test('config-set workflow._auto_chain_active false writes false to config.json', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
runGsdTools(['config-set', 'workflow._auto_chain_active', 'false'], tmpDir);
const configPath = path.join(tmpDir, '.planning', 'config.json');
assert.ok(fs.existsSync(configPath), '.planning/config.json must exist after config-set');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
assert.ok(
config.workflow !== undefined && config.workflow._auto_chain_active === false,
`Expected workflow._auto_chain_active: false in config.json, got: ${JSON.stringify(config)}`
);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-3086-git-create-tag-config-gate.test.cjs — consolidation epic #1969 (B2 #1971)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-3086-git-create-tag-config-gate (consolidation epic #1969 B2 #1971)", () => {
// allow-test-rule: workflow-markdown-is-the-runtime-contract (see #3086)
// Justification: complete-milestone.md IS the runtime — the agent reads and
// follows it directly. Asserting the <config-check> block is present in the
// markdown is the only way to verify the gate is wired. Per CONTEXT.md L611.
'use strict';
/**
* #3086 — git.create_tag config gate for milestone tagging.
*
* Tests:
* A. Default value: fresh project returns `true` for git.create_tag
* B. config-set false → config-get returns false
* C. Invalid value (e.g. "maybe") is rejected by schema validator
* D. complete-milestone.md workflow contains the <config-check> gate for git.create_tag
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
const WORKFLOW_PATH = path.join(
__dirname,
'..',
'gsd-core',
'workflows',
'complete-milestone.md',
);
describe('#3086: git.create_tag config key', () => {
test('A. fresh project: config-get git.create_tag returns true (default)', (t) => {
const tmpDir = createTempProject('gsd-3086-default-');
t.after(() => cleanup(tmpDir));
const result = runGsdTools(['config-get', 'git.create_tag'], tmpDir, { HOME: tmpDir });
assert.ok(result.success, `config-get git.create_tag failed:\n${result.error}`);
assert.strictEqual(
result.output.trim(),
'true',
`Expected default value 'true', got: '${result.output.trim()}'`,
);
});
test('B. config-set git.create_tag false → config-get returns false', (t) => {
const tmpDir = createTempProject('gsd-3086-set-false-');
t.after(() => cleanup(tmpDir));
const setResult = runGsdTools(['config-set', 'git.create_tag', 'false'], tmpDir, {
HOME: tmpDir,
});
assert.ok(setResult.success, `config-set git.create_tag false failed:\n${setResult.error}`);
const getResult = runGsdTools(['config-get', 'git.create_tag'], tmpDir, { HOME: tmpDir });
assert.ok(getResult.success, `config-get after set failed:\n${getResult.error}`);
assert.strictEqual(
getResult.output.trim(),
'false',
`Expected 'false' after set, got: '${getResult.output.trim()}'`,
);
});
test('C. config-set git.create_tag with invalid value "maybe" is rejected', (t) => {
const tmpDir = createTempProject('gsd-3086-invalid-');
t.after(() => cleanup(tmpDir));
const result = runGsdTools(['config-set', 'git.create_tag', 'maybe'], tmpDir, {
HOME: tmpDir,
});
assert.ok(
!result.success,
`Expected config-set to fail for invalid value "maybe", but it succeeded`,
);
});
test('D. complete-milestone.md contains <config-check> gate for git.create_tag', () => {
const content = fs.readFileSync(WORKFLOW_PATH, 'utf8');
assert.ok(
content.includes('git.create_tag'),
'complete-milestone.md must reference git.create_tag in a <config-check> block',
);
assert.ok(
content.includes('<config-check>'),
'complete-milestone.md must have a <config-check> block in the git_tag step',
);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/feat-3167-ship-pr-body-sections.test.cjs — consolidation epic #1969 (B2 #1971)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:feat-3167-ship-pr-body-sections (consolidation epic #1969 B2 #1971)", () => {
/**
* Regression tests for issue #3167: configurable /gsd-ship PR body sections.
*/
// allow-test-rule: source-text-is-the-product (see #3167)
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { describe, test, afterEach } = require('node:test');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
const repoRoot = path.resolve(__dirname, '..');
const tmpDirs = [];
function readRepoFile(relativePath) {
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
}
function makeProject() {
const tmpDir = createTempProject('gsd-3167-');
tmpDirs.push(tmpDir);
return tmpDir;
}
afterEach(() => {
while (tmpDirs.length) {
cleanup(tmpDirs.pop());
}
});
describe('ship.pr_body_sections config (#3167)', () => {
test('CLI config-set accepts additional PR body section arrays', () => {
const cwd = makeProject();
const value = JSON.stringify([
{
heading: 'Risks & Rollback',
enabled: true,
source: 'PLAN.md ## Risks || PLAN.md ## Rollback',
fallback: '- Rollback: revert this PR.',
},
{
heading: 'Stakeholder Sign-off',
enabled: false,
template: '- Product owner: pending',
},
]);
const result = runGsdTools(['config-set', 'ship.pr_body_sections', value, '--raw'], cwd, { HOME: cwd });
assert.equal(result.success, true, result.error);
const config = JSON.parse(fs.readFileSync(path.join(cwd, '.planning', 'config.json'), 'utf8'));
assert.deepEqual(config.ship.pr_body_sections, [
{
heading: 'Risks & Rollback',
enabled: true,
source: 'PLAN.md ## Risks || PLAN.md ## Rollback',
fallback: '- Rollback: revert this PR.',
},
{
heading: 'Stakeholder Sign-off',
enabled: false,
template: '- Product owner: pending',
},
]);
});
test('CLI config-set rejects malformed PR body section values before writing config', () => {
const cwd = makeProject();
const notArray = runGsdTools(
['config-set', 'ship.pr_body_sections', JSON.stringify({ heading: 'Not an array' }), '--raw'],
cwd,
{ HOME: cwd }
);
assert.equal(notArray.success, false);
assert.match(notArray.error, /ship\.pr_body_sections.*JSON array/);
const missingHeading = runGsdTools(
['config-set', 'ship.pr_body_sections', JSON.stringify([{ fallback: '- Missing heading' }]), '--raw'],
cwd,
{ HOME: cwd }
);
assert.equal(missingHeading.success, false);
assert.match(missingHeading.error, /heading/);
const invalidEnabled = runGsdTools(
['config-set', 'ship.pr_body_sections', JSON.stringify([{ heading: 'Toggle', enabled: 'yes', fallback: '- item' }]), '--raw'],
cwd,
{ HOME: cwd }
);
assert.equal(invalidEnabled.success, false);
assert.match(invalidEnabled.error, /enabled/);
assert.equal(fs.existsSync(path.join(cwd, '.planning', 'config.json')), false);
});
test('CLI config-new-project validates onboarded PR body sections before writing config', () => {
const cwd = makeProject();
const choices = JSON.stringify({
ship: {
pr_body_sections: [
{
heading: 'Invalid source',
source: 'package.json ## Scripts',
},
],
},
});
const result = runGsdTools(['config-new-project', choices], cwd, { HOME: cwd });
assert.equal(result.success, false);
assert.match(result.error, /source must use selectors/);
assert.equal(fs.existsSync(path.join(cwd, '.planning', 'config.json')), false);
});
test('ship workflow composes configured sections as append-only extensions', () => {
const workflow = readRepoFile('gsd-core/workflows/ship.md');
assert.match(workflow, /config-get ship\.pr_body_sections --default '\[\]'/);
assert.match(workflow, /append-only/i);
assert.match(workflow, /enabled.*false/i);
assert.match(workflow, /cannot replace/i);
assert.match(workflow, /Summary[\s\S]*Changes[\s\S]*Requirements Addressed[\s\S]*Verification[\s\S]*Key Decisions/);
assert.match(workflow, /\{phase_number\}[\s\S]*\{phase_name\}[\s\S]*\{phase_dir\}[\s\S]*\{base_branch\}[\s\S]*\{padded_phase\}/);
assert.match(workflow, /User Stories & Acceptance Criteria/);
assert.match(workflow, /Definition of Done/);
assert.match(workflow, /--body-file/);
assert.match(workflow, /trap 'rm -f "\$\{PR_BODY_FILE:-\}"' EXIT/);
});
test('default config and documentation describe ship.pr_body_sections', () => {
const template = JSON.parse(readRepoFile('gsd-core/templates/config.json'));
assert.deepEqual(template.ship.pr_body_sections, []);
const docs = readRepoFile('docs/CONFIGURATION.md');
assert.match(docs, /`ship\.pr_body_sections`/);
assert.match(docs, /additional PR body sections/i);
assert.match(docs, /append-only/i);
assert.match(docs, /lean\/agile PRD/i);
assert.match(docs, /Definition of Done/);
const planningConfig = readRepoFile('gsd-core/references/planning-config.md');
assert.match(planningConfig, /ship\.pr_body_sections/);
});
test('new-project onboarding can seed enabled or disabled PR body sections', () => {
const workflow = readRepoFile('gsd-core/workflows/new-project.md');
assert.match(workflow, /ship\.pr_body_sections/);
assert.match(workflow, /enabled.*true/);
assert.match(workflow, /enabled.*false/);
assert.match(workflow, /User Stories & Acceptance Criteria/);
assert.match(workflow, /Risks & Dependencies/);
assert.match(workflow, /Success Metrics & Release Criteria/);
assert.match(workflow, /Stakeholder Review & Approval/);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/feat-3210-fallow-schema-enum.test.cjs — consolidation epic #1969 (B2 #1971)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:feat-3210-fallow-schema-enum (consolidation epic #1969 B2 #1971)", () => {
'use strict';
/**
* Enum validation for code_quality.fallow.scope and code_quality.fallow.profile.
*
* Fixes H5 from #3424 review: config-set silently accepted invalid enum values
* (e.g. scope=fullrepo) and fell through to default behavior. This test asserts
* that invalid values are rejected with a helpful error, and valid values pass.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
describe('feat-3210 / H5: enum validation for code_quality.fallow.scope and .profile', () => {
// --- code_quality.fallow.scope ---
test('config-set code_quality.fallow.scope=fullrepo is REJECTED with helpful error', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(
['config-set', 'code_quality.fallow.scope', 'fullrepo'],
tmpDir
);
assert.ok(
!result.success,
'config-set code_quality.fallow.scope=fullrepo must fail, but it succeeded'
);
const combined = (result.output || '') + (result.error || '');
assert.ok(
combined.includes('phase') && combined.includes('repo'),
`Error message must mention valid values "phase" and "repo", got: ${combined}`
);
});
test('config-set code_quality.fallow.scope=phase is ACCEPTED', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(
['config-set', 'code_quality.fallow.scope', 'phase'],
tmpDir
);
assert.ok(
result.success,
[
'config-set code_quality.fallow.scope=phase must succeed,',
'stdout: ' + result.output,
'stderr: ' + result.error,
].join('\n')
);
});
test('config-set code_quality.fallow.scope=repo is ACCEPTED', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(
['config-set', 'code_quality.fallow.scope', 'repo'],
tmpDir
);
assert.ok(
result.success,
[
'config-set code_quality.fallow.scope=repo must succeed,',
'stdout: ' + result.output,
'stderr: ' + result.error,
].join('\n')
);
});
test('config-set code_quality.fallow.scope=PHASE (wrong case) is REJECTED', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(
['config-set', 'code_quality.fallow.scope', 'PHASE'],
tmpDir
);
assert.ok(
!result.success,
'config-set code_quality.fallow.scope=PHASE must fail (values are case-sensitive)'
);
});
// --- code_quality.fallow.profile ---
test('config-set code_quality.fallow.profile=aggressive is REJECTED with helpful error', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(
['config-set', 'code_quality.fallow.profile', 'aggressive'],
tmpDir
);
assert.ok(
!result.success,
'config-set code_quality.fallow.profile=aggressive must fail, but it succeeded'
);
const combined = (result.output || '') + (result.error || '');
assert.ok(
combined.includes('minimal') && combined.includes('standard') && combined.includes('strict'),
`Error message must mention valid values "minimal", "standard", "strict", got: ${combined}`
);
});
test('config-set code_quality.fallow.profile=minimal is ACCEPTED', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(
['config-set', 'code_quality.fallow.profile', 'minimal'],
tmpDir
);
assert.ok(
result.success,
[
'config-set code_quality.fallow.profile=minimal must succeed,',
'stdout: ' + result.output,
'stderr: ' + result.error,
].join('\n')
);
});
test('config-set code_quality.fallow.profile=standard is ACCEPTED', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(
['config-set', 'code_quality.fallow.profile', 'standard'],
tmpDir
);
assert.ok(
result.success,
[
'config-set code_quality.fallow.profile=standard must succeed,',
'stdout: ' + result.output,
'stderr: ' + result.error,
].join('\n')
);
});
test('config-set code_quality.fallow.profile=strict is ACCEPTED', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(
['config-set', 'code_quality.fallow.profile', 'strict'],
tmpDir
);
assert.ok(
result.success,
[
'config-set code_quality.fallow.profile=strict must succeed,',
'stdout: ' + result.output,
'stderr: ' + result.error,
].join('\n')
);
});
test('config-set code_quality.fallow.profile=unknown is REJECTED', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(
['config-set', 'code_quality.fallow.profile', 'unknown'],
tmpDir
);
assert.ok(
!result.success,
'config-set code_quality.fallow.profile=unknown must fail'
);
});
});
});
}

View File

@@ -1,296 +0,0 @@
'use strict';
// allow-test-rule: source-text-is-the-product
// Reads .md/.json/.yml product files whose deployed text IS what the
// runtime loads — testing text content tests the deployed contract.
/**
* Tests for ROADMAP wave dependency surfacing (#2447).
*/
const { test, describe, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
const PLAN_TEMPLATE = (wave, truths = []) => `---
phase: "1"
plan: "01-0${wave}"
type: standard
wave: ${wave}
depends_on: []
files_modified: []
autonomous: true
requirements: []
must_haves:
truths:
${truths.map(t => ` - ${t}`).join('\n') || ' - (none)'}
artifacts: []
key_links: []
---
<objective>
Plan ${wave} objective
</objective>
`;
function makePlanProject(files = {}) {
const dir = createTempProject();
fs.writeFileSync(path.join(dir, '.planning', 'ROADMAP.md'), '');
fs.mkdirSync(path.join(dir, '.planning', 'phases', '01-foundation'), { recursive: true });
for (const [rel, content] of Object.entries(files)) {
const abs = path.join(dir, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content, 'utf-8');
}
return dir;
}
describe('roadmap annotate-dependencies', () => {
let tmpDir;
afterEach(() => cleanup(tmpDir));
test('inserts wave headers for multi-wave plan set', () => {
tmpDir = makePlanProject({
'.planning/ROADMAP.md': `# Roadmap
### Phase 1: Foundation
**Goal:** Set up project
**Plans:** 2 plans
Plans:
- [ ] 01-01-PLAN.md — Set up DB
- [ ] 01-02-PLAN.md — Build API
`,
'.planning/phases/01-foundation/01-01-PLAN.md': PLAN_TEMPLATE(1, ['DB schema is correct']),
'.planning/phases/01-foundation/01-02-PLAN.md': PLAN_TEMPLATE(2, ['API returns 200']),
});
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.updated, true);
assert.strictEqual(out.waves, 2);
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(roadmap.includes('**Wave 1**'), 'Wave 1 header present');
assert.ok(roadmap.includes('**Wave 2**'), 'Wave 2 header present');
assert.ok(roadmap.includes('blocked on Wave 1'), 'Wave 2 blocked-on note present');
});
test('does not insert wave headers for single-wave plan set', () => {
tmpDir = makePlanProject({
'.planning/ROADMAP.md': `# Roadmap
### Phase 1: Foundation
**Goal:** Set up project
**Plans:** 2 plans
Plans:
- [ ] 01-01-PLAN.md — Set up DB
- [ ] 01-02-PLAN.md — Build API
`,
'.planning/phases/01-foundation/01-01-PLAN.md': PLAN_TEMPLATE(1, ['DB schema is correct']),
'.planning/phases/01-foundation/01-02-PLAN.md': PLAN_TEMPLATE(1, ['API returns 200']),
});
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(!roadmap.includes('**Wave 1**'), 'no Wave header for single-wave set');
assert.ok(!roadmap.includes('blocked on'), 'no blocked-on note for single wave');
});
test('surfaces cross-cutting constraints when truths appear in 2+ plans', () => {
const sharedTruth = 'All endpoints require auth';
tmpDir = makePlanProject({
'.planning/ROADMAP.md': `# Roadmap
### Phase 1: Foundation
**Goal:** Set up project
**Plans:** 2 plans
Plans:
- [ ] 01-01-PLAN.md — Set up DB
- [ ] 01-02-PLAN.md — Build API
`,
'.planning/phases/01-foundation/01-01-PLAN.md': PLAN_TEMPLATE(1, [sharedTruth, 'DB schema is correct']),
'.planning/phases/01-foundation/01-02-PLAN.md': PLAN_TEMPLATE(2, [sharedTruth, 'API returns 200']),
});
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.cross_cutting_constraints, 1);
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(roadmap.includes('Cross-cutting constraints:'), 'constraints subsection present');
assert.ok(roadmap.includes(sharedTruth), 'shared truth listed');
});
test('#1154: surfaces a cross-cutting backstop (object-form) truth by its statement, not dropped', () => {
// An object-form backstop truth `{ statement, verification: backstop }` (the #1154 non-inferable
// marker on must_haves.truths) shared across 2 plans must be coerced by its `statement` — the
// Hyrum backward-compat guard: a truth-reader must tolerate the new object form, never drop it.
const backstopTruth = 'statement: Adjacent touching intervals merge\n verification: backstop';
tmpDir = makePlanProject({
'.planning/ROADMAP.md': `# Roadmap
### Phase 1: Foundation
**Goal:** Set up project
**Plans:** 2 plans
Plans:
- [ ] 01-01-PLAN.md — Set up DB
- [ ] 01-02-PLAN.md — Build API
`,
'.planning/phases/01-foundation/01-01-PLAN.md': PLAN_TEMPLATE(1, [backstopTruth, 'DB schema is correct']),
'.planning/phases/01-foundation/01-02-PLAN.md': PLAN_TEMPLATE(2, [backstopTruth, 'API returns 200']),
});
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.cross_cutting_constraints, 1, 'the shared backstop truth is surfaced, not dropped');
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(roadmap.includes('Adjacent touching intervals merge'), 'surfaced by its statement text, not [object Object]');
});
test('does not surface constraints that appear in only one plan', () => {
tmpDir = makePlanProject({
'.planning/ROADMAP.md': `# Roadmap
### Phase 1: Foundation
**Goal:** Set up project
**Plans:** 2 plans
Plans:
- [ ] 01-01-PLAN.md — Set up DB
- [ ] 01-02-PLAN.md — Build API
`,
'.planning/phases/01-foundation/01-01-PLAN.md': PLAN_TEMPLATE(1, ['Only in plan 1']),
'.planning/phases/01-foundation/01-02-PLAN.md': PLAN_TEMPLATE(2, ['Only in plan 2']),
});
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.cross_cutting_constraints, 0);
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(!roadmap.includes('Cross-cutting constraints:'), 'no constraints section when none are cross-cutting');
});
test('is idempotent — running twice does not double-insert wave headers', () => {
tmpDir = makePlanProject({
'.planning/ROADMAP.md': `# Roadmap
### Phase 1: Foundation
**Goal:** Set up project
**Plans:** 2 plans
Plans:
- [ ] 01-01-PLAN.md — Set up DB
- [ ] 01-02-PLAN.md — Build API
`,
'.planning/phases/01-foundation/01-01-PLAN.md': PLAN_TEMPLATE(1),
'.planning/phases/01-foundation/01-02-PLAN.md': PLAN_TEMPLATE(2),
});
runGsdTools('roadmap annotate-dependencies 1', tmpDir);
const secondResult = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(secondResult.success);
const out = JSON.parse(secondResult.output);
assert.strictEqual(out.updated, false, 'second run should be no-op');
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
const waveMatches = roadmap.match(/\*\*Wave \d+\*\*/g) || [];
assert.strictEqual(waveMatches.length, 2, 'exactly 2 wave headers (not doubled)');
});
test('returns no-op when phase has no plans', () => {
tmpDir = makePlanProject({
'.planning/ROADMAP.md': `# Roadmap\n\n### Phase 1: Foundation\n**Goal:** Set up project\n`,
});
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(result.success);
const out = JSON.parse(result.output);
assert.strictEqual(out.updated, false);
});
test('#2757: truths containing colons do not crash annotate-dependencies', () => {
// Unquoted truths with colons (Rails idioms: db:seed, /foo/:id, Class::Method)
// caused parseMustHavesBlock to return {} instead of a string, then t.trim() threw.
const colonTruths = [
'GET /foo/:id resolves to controller#show',
'Class::Method is idempotent',
'"Quoted truth with colon: inside"',
];
tmpDir = makePlanProject({
'.planning/ROADMAP.md': `# Roadmap\n\n### Phase 1: Foundation\n**Goal:** Set up project\n**Plans:** 1 plan\n\nPlans:\n- [ ] 01-01-PLAN.md — Repro plan\n`,
'.planning/phases/01-foundation/01-01-PLAN.md': PLAN_TEMPLATE(1, colonTruths),
});
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(result.success, `Command threw on colon-containing truths: ${result.error}`);
const out = JSON.parse(result.output);
assert.ok(typeof out.updated === 'boolean', 'should return a valid result object');
});
test('#314 map-lookup: found-path uses plan wave, miss-path defaults to wave 1', () => {
// Behavior lock for the O(1) Map swap: asserts BOTH branches of the lookup.
// - 01-01-PLAN.md is in planData (wave 2) → checklist line must land under Wave 2.
// - 01-99-PLAN.md is NOT in planData → null-on-miss → defaults to wave 1.
tmpDir = makePlanProject({
'.planning/ROADMAP.md': `# Roadmap
### Phase 1: Foundation
**Goal:** Set up project
**Plans:** 2 plans
Plans:
- [ ] 01-01-PLAN.md — Known plan
- [ ] 01-99-PLAN.md — Unknown plan (no PLAN.md)
`,
'.planning/phases/01-foundation/01-01-PLAN.md': PLAN_TEMPLATE(2),
// 01-99-PLAN.md intentionally absent — simulates a checklist entry with no backing plan file
});
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
// Both waves must be present (wave 1 from the miss, wave 2 from the found entry)
assert.ok(roadmap.includes('**Wave 1**'), 'Wave 1 header present (miss-path default)');
assert.ok(roadmap.includes('**Wave 2**'), 'Wave 2 header present (found-path)');
// Known plan (wave 2) must appear AFTER Wave 2 header
const wave2Idx = roadmap.indexOf('**Wave 2**');
const knownLineIdx = roadmap.indexOf('01-01-PLAN.md');
assert.ok(knownLineIdx > wave2Idx, 'known plan line grouped under Wave 2');
// Unknown plan (wave 1 default) must appear AFTER Wave 1 header and BEFORE Wave 2 header
const wave1Idx = roadmap.indexOf('**Wave 1**');
const unknownLineIdx = roadmap.indexOf('01-99-PLAN.md');
assert.ok(unknownLineIdx > wave1Idx, 'unknown plan line grouped under Wave 1');
assert.ok(unknownLineIdx < wave2Idx, 'unknown plan line appears before Wave 2 section');
});
test('plan-phase.md documents annotate-dependencies step', () => {
const planPhase = fs.readFileSync(
path.join(__dirname, '../gsd-core/workflows/plan-phase.md'), 'utf-8'
);
assert.ok(planPhase.includes('annotate-dependencies'), 'plan-phase.md references annotate-dependencies command');
assert.ok(planPhase.includes('13d'), 'plan-phase.md has step 13d');
assert.ok(planPhase.includes('Cross-cutting constraints'), 'plan-phase.md documents cross-cutting constraints');
});
});

View File

@@ -1,160 +0,0 @@
/**
* Regression tests for issue #3167: configurable /gsd-ship PR body sections.
*/
// allow-test-rule: source-text-is-the-product
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { describe, test, afterEach } = require('node:test');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
const repoRoot = path.resolve(__dirname, '..');
const tmpDirs = [];
function readRepoFile(relativePath) {
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
}
function makeProject() {
const tmpDir = createTempProject('gsd-3167-');
tmpDirs.push(tmpDir);
return tmpDir;
}
afterEach(() => {
while (tmpDirs.length) {
cleanup(tmpDirs.pop());
}
});
describe('ship.pr_body_sections config (#3167)', () => {
test('CLI config-set accepts additional PR body section arrays', () => {
const cwd = makeProject();
const value = JSON.stringify([
{
heading: 'Risks & Rollback',
enabled: true,
source: 'PLAN.md ## Risks || PLAN.md ## Rollback',
fallback: '- Rollback: revert this PR.',
},
{
heading: 'Stakeholder Sign-off',
enabled: false,
template: '- Product owner: pending',
},
]);
const result = runGsdTools(['config-set', 'ship.pr_body_sections', value, '--raw'], cwd, { HOME: cwd });
assert.equal(result.success, true, result.error);
const config = JSON.parse(fs.readFileSync(path.join(cwd, '.planning', 'config.json'), 'utf8'));
assert.deepEqual(config.ship.pr_body_sections, [
{
heading: 'Risks & Rollback',
enabled: true,
source: 'PLAN.md ## Risks || PLAN.md ## Rollback',
fallback: '- Rollback: revert this PR.',
},
{
heading: 'Stakeholder Sign-off',
enabled: false,
template: '- Product owner: pending',
},
]);
});
test('CLI config-set rejects malformed PR body section values before writing config', () => {
const cwd = makeProject();
const notArray = runGsdTools(
['config-set', 'ship.pr_body_sections', JSON.stringify({ heading: 'Not an array' }), '--raw'],
cwd,
{ HOME: cwd }
);
assert.equal(notArray.success, false);
assert.match(notArray.error, /ship\.pr_body_sections.*JSON array/);
const missingHeading = runGsdTools(
['config-set', 'ship.pr_body_sections', JSON.stringify([{ fallback: '- Missing heading' }]), '--raw'],
cwd,
{ HOME: cwd }
);
assert.equal(missingHeading.success, false);
assert.match(missingHeading.error, /heading/);
const invalidEnabled = runGsdTools(
['config-set', 'ship.pr_body_sections', JSON.stringify([{ heading: 'Toggle', enabled: 'yes', fallback: '- item' }]), '--raw'],
cwd,
{ HOME: cwd }
);
assert.equal(invalidEnabled.success, false);
assert.match(invalidEnabled.error, /enabled/);
assert.equal(fs.existsSync(path.join(cwd, '.planning', 'config.json')), false);
});
test('CLI config-new-project validates onboarded PR body sections before writing config', () => {
const cwd = makeProject();
const choices = JSON.stringify({
ship: {
pr_body_sections: [
{
heading: 'Invalid source',
source: 'package.json ## Scripts',
},
],
},
});
const result = runGsdTools(['config-new-project', choices], cwd, { HOME: cwd });
assert.equal(result.success, false);
assert.match(result.error, /source must use selectors/);
assert.equal(fs.existsSync(path.join(cwd, '.planning', 'config.json')), false);
});
test('ship workflow composes configured sections as append-only extensions', () => {
const workflow = readRepoFile('gsd-core/workflows/ship.md');
assert.match(workflow, /config-get ship\.pr_body_sections --default '\[\]'/);
assert.match(workflow, /append-only/i);
assert.match(workflow, /enabled.*false/i);
assert.match(workflow, /cannot replace/i);
assert.match(workflow, /Summary[\s\S]*Changes[\s\S]*Requirements Addressed[\s\S]*Verification[\s\S]*Key Decisions/);
assert.match(workflow, /\{phase_number\}[\s\S]*\{phase_name\}[\s\S]*\{phase_dir\}[\s\S]*\{base_branch\}[\s\S]*\{padded_phase\}/);
assert.match(workflow, /User Stories & Acceptance Criteria/);
assert.match(workflow, /Definition of Done/);
assert.match(workflow, /--body-file/);
assert.match(workflow, /trap 'rm -f "\$\{PR_BODY_FILE:-\}"' EXIT/);
});
test('default config and documentation describe ship.pr_body_sections', () => {
const template = JSON.parse(readRepoFile('gsd-core/templates/config.json'));
assert.deepEqual(template.ship.pr_body_sections, []);
const docs = readRepoFile('docs/CONFIGURATION.md');
assert.match(docs, /`ship\.pr_body_sections`/);
assert.match(docs, /additional PR body sections/i);
assert.match(docs, /append-only/i);
assert.match(docs, /lean\/agile PRD/i);
assert.match(docs, /Definition of Done/);
const planningConfig = readRepoFile('gsd-core/references/planning-config.md');
assert.match(planningConfig, /ship\.pr_body_sections/);
});
test('new-project onboarding can seed enabled or disabled PR body sections', () => {
const workflow = readRepoFile('gsd-core/workflows/new-project.md');
assert.match(workflow, /ship\.pr_body_sections/);
assert.match(workflow, /enabled.*true/);
assert.match(workflow, /enabled.*false/);
assert.match(workflow, /User Stories & Acceptance Criteria/);
assert.match(workflow, /Risks & Dependencies/);
assert.match(workflow, /Success Metrics & Release Criteria/);
assert.match(workflow, /Stakeholder Review & Approval/);
});
});

View File

@@ -1,175 +0,0 @@
'use strict';
/**
* Enum validation for code_quality.fallow.scope and code_quality.fallow.profile.
*
* Fixes H5 from #3424 review: config-set silently accepted invalid enum values
* (e.g. scope=fullrepo) and fell through to default behavior. This test asserts
* that invalid values are rejected with a helpful error, and valid values pass.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
describe('feat-3210 / H5: enum validation for code_quality.fallow.scope and .profile', () => {
// --- code_quality.fallow.scope ---
test('config-set code_quality.fallow.scope=fullrepo is REJECTED with helpful error', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(
['config-set', 'code_quality.fallow.scope', 'fullrepo'],
tmpDir
);
assert.ok(
!result.success,
'config-set code_quality.fallow.scope=fullrepo must fail, but it succeeded'
);
const combined = (result.output || '') + (result.error || '');
assert.ok(
combined.includes('phase') && combined.includes('repo'),
`Error message must mention valid values "phase" and "repo", got: ${combined}`
);
});
test('config-set code_quality.fallow.scope=phase is ACCEPTED', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(
['config-set', 'code_quality.fallow.scope', 'phase'],
tmpDir
);
assert.ok(
result.success,
[
'config-set code_quality.fallow.scope=phase must succeed,',
'stdout: ' + result.output,
'stderr: ' + result.error,
].join('\n')
);
});
test('config-set code_quality.fallow.scope=repo is ACCEPTED', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(
['config-set', 'code_quality.fallow.scope', 'repo'],
tmpDir
);
assert.ok(
result.success,
[
'config-set code_quality.fallow.scope=repo must succeed,',
'stdout: ' + result.output,
'stderr: ' + result.error,
].join('\n')
);
});
test('config-set code_quality.fallow.scope=PHASE (wrong case) is REJECTED', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(
['config-set', 'code_quality.fallow.scope', 'PHASE'],
tmpDir
);
assert.ok(
!result.success,
'config-set code_quality.fallow.scope=PHASE must fail (values are case-sensitive)'
);
});
// --- code_quality.fallow.profile ---
test('config-set code_quality.fallow.profile=aggressive is REJECTED with helpful error', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(
['config-set', 'code_quality.fallow.profile', 'aggressive'],
tmpDir
);
assert.ok(
!result.success,
'config-set code_quality.fallow.profile=aggressive must fail, but it succeeded'
);
const combined = (result.output || '') + (result.error || '');
assert.ok(
combined.includes('minimal') && combined.includes('standard') && combined.includes('strict'),
`Error message must mention valid values "minimal", "standard", "strict", got: ${combined}`
);
});
test('config-set code_quality.fallow.profile=minimal is ACCEPTED', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(
['config-set', 'code_quality.fallow.profile', 'minimal'],
tmpDir
);
assert.ok(
result.success,
[
'config-set code_quality.fallow.profile=minimal must succeed,',
'stdout: ' + result.output,
'stderr: ' + result.error,
].join('\n')
);
});
test('config-set code_quality.fallow.profile=standard is ACCEPTED', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(
['config-set', 'code_quality.fallow.profile', 'standard'],
tmpDir
);
assert.ok(
result.success,
[
'config-set code_quality.fallow.profile=standard must succeed,',
'stdout: ' + result.output,
'stderr: ' + result.error,
].join('\n')
);
});
test('config-set code_quality.fallow.profile=strict is ACCEPTED', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(
['config-set', 'code_quality.fallow.profile', 'strict'],
tmpDir
);
assert.ok(
result.success,
[
'config-set code_quality.fallow.profile=strict must succeed,',
'stdout: ' + result.output,
'stderr: ' + result.error,
].join('\n')
);
});
test('config-set code_quality.fallow.profile=unknown is REJECTED', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(
['config-set', 'code_quality.fallow.profile', 'unknown'],
tmpDir
);
assert.ok(
!result.success,
'config-set code_quality.fallow.profile=unknown must fail'
);
});
});

View File

@@ -1,209 +0,0 @@
/**
* Tests for the --json-errors mode added in #3255.
*
* When gsd-tools is invoked with --json-errors, all error() calls emit a
* structured JSON object to stderr:
*
* { ok: false, reason: "<error_code>", message: "<human text>" }
*
* This lets tests assert on typed reason codes instead of grepping free-form
* stderr text. All assertions below parse the captured stderr via JSON.parse
* and inspect typed fields — never result.error.includes() (#2974 / k001).
*
* Covered error paths (representative set, each exercises a different branch):
* 1. Unknown top-level command → reason: "sdk_unknown_command"
* 2. Unknown dotted command → reason: "sdk_unknown_command"
* 3. Missing required argument → reason: "usage" (--pick without value)
* 4. Config key not found → reason: "config_key_not_found"
* 5. Unknown subcommand → reason: "sdk_unknown_command"
* 6. GSD_JSON_ERRORS=1 env var → same structured output without --flag
* 7. Successful command unaffected
* 8. Error object shape is stable ({ok, reason, message})
* 9. Single error line per invocation
* 10. Unknown flag → reason: "usage"
*/
'use strict';
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
// Helper: run gsd-tools with --json-errors and parse the structured stderr.
// Returns the parsed object, or throws if stderr is not valid JSON.
function runJsonErrors(args, tmpDir, env = {}) {
const allArgs = ['--json-errors', ...args];
const result = runGsdTools(allArgs, tmpDir, env);
// Must have failed
assert.strictEqual(result.success, false,
`Expected failure with --json-errors for args: ${args.join(' ')}\nstdout: ${result.output}\nstderr: ${result.error}`);
let parsed;
try {
parsed = JSON.parse(result.error);
} catch (e) {
throw new Error(
`--json-errors must emit valid JSON on stderr.\n` +
`Args: ${args.join(' ')}\n` +
`stderr: ${result.error}\n` +
`parse error: ${e.message}`
);
}
return parsed;
}
describe('feat #3255: --json-errors mode emits structured error objects', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
// ── 1. Unknown top-level command ─────────────────────────────────────────
test('unknown top-level command emits { ok: false, reason: "sdk_unknown_command" }', () => {
const parsed = runJsonErrors(['totally-unknown-command-xyzzy'], tmpDir);
assert.strictEqual(parsed.ok, false,
'error object must have ok: false');
assert.strictEqual(parsed.reason, 'sdk_unknown_command',
`reason must be "sdk_unknown_command", got: ${parsed.reason}`);
assert.ok(typeof parsed.message === 'string' && parsed.message.length > 0,
'message must be a non-empty string');
});
// ── 2. Unknown dotted command ────────────────────────────────────────────
test('unknown dotted command (foo.bar) emits { ok: false, reason: "sdk_unknown_command" }', () => {
const parsed = runJsonErrors(['foo.bar'], tmpDir);
assert.strictEqual(parsed.ok, false,
'error object must have ok: false');
assert.strictEqual(parsed.reason, 'sdk_unknown_command',
`dotted unknown command reason must be "sdk_unknown_command", got: ${parsed.reason}`);
assert.ok(typeof parsed.message === 'string' && parsed.message.length > 0,
'message must be a non-empty string');
});
// ── 3. Missing --pick value ───────────────────────────────────────────────
test('--pick without value emits { ok: false, reason: "usage" }', () => {
const parsed = runJsonErrors(['generate-slug', 'test-text', '--pick'], tmpDir);
assert.strictEqual(parsed.ok, false,
'error object must have ok: false');
assert.strictEqual(parsed.reason, 'usage',
`missing --pick value reason must be "usage", got: ${parsed.reason}`);
assert.ok(typeof parsed.message === 'string' && parsed.message.length > 0,
'message must be a non-empty string');
});
// ── 4. Config key not found ───────────────────────────────────────────────
test('config-get for absent key emits { ok: false, reason: "config_key_not_found" }', () => {
// Initialise config.json first so we reach the "key not found" branch
// rather than the "no config.json" branch.
runGsdTools(['config-ensure-section'], tmpDir);
const parsed = runJsonErrors(['config-get', 'nonexistent_config_key_xyzzy'], tmpDir);
assert.strictEqual(parsed.ok, false,
'error object must have ok: false');
assert.strictEqual(parsed.reason, 'config_key_not_found',
`reason must be "config_key_not_found", got: ${parsed.reason}`);
assert.ok(typeof parsed.message === 'string' && parsed.message.length > 0,
'message must be a non-empty string');
});
// ── 5. Unknown subcommand within a domain ────────────────────────────────
test('unknown intel subcommand emits { ok: false, reason: "sdk_unknown_command" }', () => {
const parsed = runJsonErrors(['intel', 'bogus-subcommand-xyzzy'], tmpDir);
assert.strictEqual(parsed.ok, false,
'error object must have ok: false');
assert.strictEqual(parsed.reason, 'sdk_unknown_command',
`unknown subcommand reason must be "sdk_unknown_command", got: ${parsed.reason}`);
assert.ok(typeof parsed.message === 'string' && parsed.message.length > 0,
'message must be a non-empty string');
});
// ── 6. GSD_JSON_ERRORS=1 env var activates structured mode ───────────────
test('GSD_JSON_ERRORS=1 env var produces same structured error as --json-errors flag', () => {
// Run with env var instead of --json-errors flag
const result = runGsdTools(
['totally-unknown-command-xyzzy'],
tmpDir,
{ GSD_JSON_ERRORS: '1' }
);
assert.strictEqual(result.success, false,
'command must fail');
let parsed;
try {
parsed = JSON.parse(result.error);
} catch (e) {
throw new Error(
`GSD_JSON_ERRORS=1 must emit valid JSON on stderr.\n` +
`stderr: ${result.error}\n` +
`parse error: ${e.message}`
);
}
assert.strictEqual(parsed.ok, false,
'error object must have ok: false');
assert.strictEqual(parsed.reason, 'sdk_unknown_command',
`reason must be "sdk_unknown_command", got: ${parsed.reason}`);
});
// ── 7. Successful commands are unaffected by --json-errors ───────────────
test('successful command with --json-errors flag still succeeds normally', () => {
const result = runGsdTools(
['--json-errors', 'generate-slug', 'hello-world'],
tmpDir
);
assert.strictEqual(result.success, true,
`Successful command must not be broken by --json-errors flag.\nstderr: ${result.error}`);
assert.ok(result.output.length > 0,
'stdout must be non-empty for successful generate-slug');
});
// ── 8. Error object shape is stable (no extra top-level keys) ────────────
test('error object contains exactly {ok, reason, message} — no extra keys', () => {
const parsed = runJsonErrors(['totally-unknown-command-xyzzy'], tmpDir);
const keys = Object.keys(parsed).sort();
assert.deepStrictEqual(keys, ['message', 'ok', 'reason'],
`error object must have exactly {ok, reason, message}. Got keys: ${keys.join(', ')}`);
});
// ── 9. Multiple errors in one session: only the first error is emitted ───
test('only one error JSON line is emitted per invocation (process exits on first error)', () => {
const result = runGsdTools(
['--json-errors', 'totally-unknown-command-xyzzy'],
tmpDir
);
assert.strictEqual(result.success, false, 'must fail');
const lines = result.error.trim().split('\n').filter(l => l.length > 0);
assert.strictEqual(lines.length, 1,
`stderr must contain exactly one JSON line, got ${lines.length}:\n${result.error}`);
// Also verify the single line is valid JSON
const parsed = JSON.parse(lines[0]);
assert.strictEqual(parsed.ok, false);
});
// ── 10. Unknown flag emits { ok: false, reason: "usage" } ────────────────
test('unknown version flag emits { ok: false, reason: "usage" }', () => {
const parsed = runJsonErrors(['--version', 'generate-slug', 'x'], tmpDir);
assert.strictEqual(parsed.ok, false, 'error object must have ok: false');
assert.strictEqual(parsed.reason, 'usage',
`--version flag reason must be "usage", got: ${parsed.reason}`);
});
});

View File

@@ -1,225 +0,0 @@
/**
* Follow-up tests for #3310: every remaining `error()` call at a subcommand
* boundary or usage check in `gsd-tools.cjs` carries a typed `ERROR_REASON`.
*
* #3304 wired four representative paths (unknown top-level command, unknown
* intel subcommand, missing --pick value, --version flag). The rest fell
* through to `ERROR_REASON.UNKNOWN`. This file locks the post-#3310 contract:
*
* - Every "Unknown <subsystem> subcommand" emits reason: "sdk_unknown_command".
* - Every "Usage: ..." / missing-required-arg path emits reason: "usage".
*
* All assertions parse stderr via JSON.parse — never `.includes()` — per the
* #2974 / CONTRIBUTING.md "Prohibited: Raw Text Matching" rule.
*/
'use strict';
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
// Run gsd-tools with GSD_JSON_ERRORS=1 (env-var activation, exercises the
// path #3304 added alongside the --json-errors flag) and parse the
// structured stderr. Returns the parsed object; throws if stderr is not JSON.
function runJsonErrors(args, tmpDir, env = {}) {
const result = runGsdTools(args, tmpDir, { ...env, GSD_JSON_ERRORS: '1' });
assert.strictEqual(result.success, false,
`Expected failure with GSD_JSON_ERRORS=1 for args: ${args.join(' ')}\n` +
`stdout: ${result.output}\nstderr: ${result.error}`);
let parsed;
try {
parsed = JSON.parse(result.error);
} catch (e) {
throw new Error(
`GSD_JSON_ERRORS=1 must emit valid JSON on stderr.\n` +
`Args: ${args.join(' ')}\nstderr: ${result.error}\nparse error: ${e.message}`
);
}
return parsed;
}
// Assert the typed-IR contract: object shape + reason. Keeps the per-test
// boilerplate minimal so each error-path test reads as a single fact.
function assertTypedError(parsed, expectedReason, label) {
assert.strictEqual(parsed.ok, false,
`${label}: error object must have ok: false`);
assert.strictEqual(parsed.reason, expectedReason,
`${label}: reason must be "${expectedReason}", got: ${parsed.reason}`);
assert.ok(typeof parsed.message === 'string' && parsed.message.length > 0,
`${label}: message must be a non-empty string`);
}
describe('feat #3310: typed ERROR_REASON codes on remaining error paths', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
// ── Unknown <subsystem> subcommand → SDK_UNKNOWN_COMMAND ────────────────
// Each of these used to fall through to reason: "unknown" before #3310.
test('unknown template subcommand → sdk_unknown_command', () => {
const parsed = runJsonErrors(['template', 'bogus-subcommand-xyzzy'], tmpDir);
assertTypedError(parsed, 'sdk_unknown_command', 'template');
});
test('unknown frontmatter subcommand → sdk_unknown_command', () => {
// frontmatter expects subcommand at args[1] and file at args[2]; pass a
// bogus subcommand with a placeholder file so we definitely reach the
// unknown-subcommand branch, not an earlier validation.
const parsed = runJsonErrors(
['frontmatter', 'bogus-subcommand-xyzzy', 'placeholder.md'],
tmpDir
);
assertTypedError(parsed, 'sdk_unknown_command', 'frontmatter');
});
test('unknown requirements subcommand → sdk_unknown_command', () => {
const parsed = runJsonErrors(['requirements', 'bogus-subcommand-xyzzy'], tmpDir);
assertTypedError(parsed, 'sdk_unknown_command', 'requirements');
});
test('unknown milestone subcommand → sdk_unknown_command', () => {
const parsed = runJsonErrors(['milestone', 'bogus-subcommand-xyzzy'], tmpDir);
assertTypedError(parsed, 'sdk_unknown_command', 'milestone');
});
test('unknown uat subcommand → sdk_unknown_command', () => {
const parsed = runJsonErrors(['uat', 'bogus-subcommand-xyzzy'], tmpDir);
assertTypedError(parsed, 'sdk_unknown_command', 'uat');
});
test('unknown todo subcommand → sdk_unknown_command', () => {
const parsed = runJsonErrors(['todo', 'bogus-subcommand-xyzzy'], tmpDir);
assertTypedError(parsed, 'sdk_unknown_command', 'todo');
});
test('unknown workstream subcommand → sdk_unknown_command', () => {
const parsed = runJsonErrors(['workstream', 'bogus-subcommand-xyzzy'], tmpDir);
assertTypedError(parsed, 'sdk_unknown_command', 'workstream');
});
test('unknown graphify subcommand → sdk_unknown_command', () => {
const parsed = runJsonErrors(['graphify', 'bogus-subcommand-xyzzy'], tmpDir);
assertTypedError(parsed, 'sdk_unknown_command', 'graphify');
});
test('unknown learnings subcommand → sdk_unknown_command', () => {
const parsed = runJsonErrors(['learnings', 'bogus-subcommand-xyzzy'], tmpDir);
assertTypedError(parsed, 'sdk_unknown_command', 'learnings');
});
// ── Missing required positional/flag values → USAGE ─────────────────────
// These previously emitted reason: "unknown" because the second argument
// to error() was absent.
test('missing --cwd value → usage', () => {
// The --cwd flag is consumed before the command dispatcher; passing it
// bare with no following value triggers the usage error at L253/L258.
const parsed = runJsonErrors(['--cwd'], tmpDir);
assertTypedError(parsed, 'usage', '--cwd missing value');
});
test('invalid --cwd directory → usage', () => {
// --cwd <nonexistent-path> hits the existsSync / isDirectory check at L264.
const parsed = runJsonErrors(
['--cwd', '/this/path/should/not/exist/anywhere/xyzzy', 'state', 'load'],
tmpDir
);
assertTypedError(parsed, 'usage', 'invalid --cwd directory');
});
test('intel query missing term → usage', () => {
const parsed = runJsonErrors(['intel', 'query'], tmpDir);
assertTypedError(parsed, 'usage', 'intel query missing term');
});
test('intel patch-meta missing file path → usage', () => {
const parsed = runJsonErrors(['intel', 'patch-meta'], tmpDir);
assertTypedError(parsed, 'usage', 'intel patch-meta missing file');
});
test('intel extract-exports missing file path → usage', () => {
const parsed = runJsonErrors(['intel', 'extract-exports'], tmpDir);
assertTypedError(parsed, 'usage', 'intel extract-exports missing file');
});
test('graphify query missing term → usage', () => {
const parsed = runJsonErrors(['graphify', 'query'], tmpDir);
assertTypedError(parsed, 'usage', 'graphify query missing term');
});
test('learnings query missing --tag → usage', () => {
const parsed = runJsonErrors(['learnings', 'query'], tmpDir);
assertTypedError(parsed, 'usage', 'learnings query missing --tag');
});
test('learnings prune missing --older-than → usage', () => {
const parsed = runJsonErrors(['learnings', 'prune'], tmpDir);
assertTypedError(parsed, 'usage', 'learnings prune missing --older-than');
});
test('learnings delete missing id → usage', () => {
const parsed = runJsonErrors(['learnings', 'delete'], tmpDir);
assertTypedError(parsed, 'usage', 'learnings delete missing id');
});
test('extract-messages missing project arg → usage', () => {
// L877 — args[1] is undefined or starts with '--'.
const parsed = runJsonErrors(['extract-messages'], tmpDir);
assertTypedError(parsed, 'usage', 'extract-messages missing project');
});
test('write-profile missing --input → usage', () => {
const parsed = runJsonErrors(['write-profile'], tmpDir);
assertTypedError(parsed, 'usage', 'write-profile missing --input');
});
test('detect-custom-files missing --config-dir → usage', () => {
const parsed = runJsonErrors(['detect-custom-files'], tmpDir);
assertTypedError(parsed, 'usage', 'detect-custom-files missing --config-dir');
});
test('detect-custom-files invalid --config-dir → usage', () => {
const parsed = runJsonErrors(
['detect-custom-files', '--config-dir', '/nonexistent/path/xyzzy'],
tmpDir
);
assertTypedError(parsed, 'usage', 'detect-custom-files invalid --config-dir');
});
// ── Shape regression guard: every newly-typed path emits the canonical
// {ok, reason, message} object — no leakage of reason: "unknown". ────
test('every remaining typed path emits the canonical {ok, reason, message} shape', () => {
const probes = [
['template', 'bogus'],
['frontmatter', 'bogus', 'placeholder.md'],
['requirements', 'bogus'],
['milestone', 'bogus'],
['uat', 'bogus'],
['todo', 'bogus'],
['workstream', 'bogus'],
['graphify', 'bogus'],
['learnings', 'bogus'],
['intel', 'query'],
['extract-messages'],
['write-profile'],
['detect-custom-files'],
];
for (const args of probes) {
const parsed = runJsonErrors(args, tmpDir);
const keys = Object.keys(parsed).sort();
assert.deepStrictEqual(keys, ['message', 'ok', 'reason'],
`args ${args.join(' ')}: keys must be exactly {ok,reason,message}, got ${keys.join(',')}`);
assert.notStrictEqual(parsed.reason, 'unknown',
`args ${args.join(' ')}: reason must be a typed code, not the fallback "unknown"`);
}
});
});

View File

@@ -1,125 +0,0 @@
'use strict';
/**
* Regression tests for `gsd-tools query phase.list-plans <N>` (#1437).
*
* Prior to this fix, `phase.list-plans` was not registered in the
* phase-command-router, so any invocation produced:
* "Error: Unknown phase subcommand. Available: uat-passed, next-decimal, ..."
*
* These tests exercise the full dispatch path:
* gsd-tools → phase-command-router → phase.cmdPhaseListPlans
*/
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
// ─── Fixture helpers ──────────────────────────────────────────────────────────
function setupProject(phaseSlug = '01-feature') {
const tmpDir = createTempProject();
// Minimal ROADMAP so findPhaseInternal can resolve the phase directory
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'# Roadmap',
'',
'- [ ] Phase 1: Feature',
'',
'### Phase 1: Feature',
'**Goal:** Build feature',
'**Plans:** 1 plans',
'',
].join('\n'),
);
const phaseDir = path.join(tmpDir, '.planning', 'phases', phaseSlug);
fs.mkdirSync(phaseDir, { recursive: true });
return { tmpDir, phaseDir };
}
function touch(dir, ...files) {
for (const f of files) {
fs.writeFileSync(path.join(dir, f), '');
}
}
// ─── Tests ────────────────────────────────────────────────────────────────────
let tmpDir;
let phaseDir;
beforeEach(() => {
const proj = setupProject('01-feature');
tmpDir = proj.tmpDir;
phaseDir = proj.phaseDir;
});
afterEach(() => {
cleanup(tmpDir);
});
describe('bug-1437 — phase.list-plans is wired in gsd-tools', () => {
test('command no longer returns Unknown phase subcommand error', () => {
touch(phaseDir, '01-01-PLAN.md');
const result = runGsdTools(['query', 'phase.list-plans', '1'], tmpDir);
// Previously this would fail with "Unknown phase subcommand"
assert.ok(result.success, `Command failed: ${result.error}\nOutput: ${result.output}`);
assert.ok(!result.error || !result.error.includes('Unknown phase subcommand'),
`got unexpected error: ${result.error}`);
});
test('returns JSON with plan_count and plans array when plans exist', () => {
touch(phaseDir, '01-01-PLAN.md', '01-02-PLAN.md');
const result = runGsdTools(['query', 'phase.list-plans', '1', '--raw'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}\nOutput: ${result.output}`);
const data = JSON.parse(result.output);
assert.equal(data.plan_count, 2, 'plan_count should be 2');
assert.equal(data.has_plans, true, 'has_plans should be true');
assert.ok(Array.isArray(data.plans), 'plans should be an array');
assert.equal(data.plans.length, 2, 'plans array should have 2 entries');
});
test('returns plan_count 0 and empty plans array when phase has no plan files', () => {
// Phase directory exists but has no *-PLAN.md files
touch(phaseDir, 'CONTEXT.md');
const result = runGsdTools(['query', 'phase.list-plans', '1', '--raw'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}\nOutput: ${result.output}`);
const data = JSON.parse(result.output);
assert.equal(data.plan_count, 0);
assert.equal(data.has_plans, false);
assert.deepEqual(data.plans, []);
});
test('returns has_plans false when phase number is not found', () => {
// Phase 99 does not exist in the fixture
const result = runGsdTools(['query', 'phase.list-plans', '99', '--raw'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}\nOutput: ${result.output}`);
const data = JSON.parse(result.output);
assert.equal(data.has_plans, false);
assert.equal(data.plan_count, 0);
});
test('plan paths are relative to project root and posix-style', () => {
touch(phaseDir, '01-01-PLAN.md');
const result = runGsdTools(['query', 'phase.list-plans', '1', '--raw'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}\nOutput: ${result.output}`);
const data = JSON.parse(result.output);
assert.equal(data.plans.length, 1);
// Paths must be forward-slash separated (posix) and relative (not absolute)
const planPath = data.plans[0];
assert.ok(!path.isAbsolute(planPath), `expected relative path, got: ${planPath}`);
assert.ok(!planPath.includes('\\'), `expected posix path, got: ${planPath}`);
assert.ok(planPath.includes('01-01-PLAN.md'), `expected plan filename in path: ${planPath}`);
});
test('dotted form phase.list-plans (without query prefix) also works', () => {
touch(phaseDir, '01-01-PLAN.md');
const result = runGsdTools(['phase.list-plans', '1', '--raw'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}\nOutput: ${result.output}`);
const data = JSON.parse(result.output);
assert.equal(data.plan_count, 1);
});
});

View File

@@ -381,3 +381,539 @@ describe('stateReplaceFieldWithFallback field-miss warning', () => {
assert.ok(typeof output.status === 'string', 'Should return a status string');
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/6-validate-cjs-drift-regression.test.cjs — consolidation epic #1969 (B2 #1971)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:6-validate-cjs-drift-regression (consolidation epic #1969 B2 #1971)", () => {
'use strict';
/**
* Regression tests for issue #6 (open-gsd/gsd-core):
* Three validation behaviors present in validate.ts are missing from verify.cjs,
* producing silent false negatives on the CJS production path.
*
* Three drift items fixed by porting phaseVariants() and activeDiskPhases to verify.cjs:
*
* 1. W007 activeDiskPhases — verify.cjs uses diskPhases (includes archived) for the
* W007 check; validate.ts uses activeDiskPhases (active phasesDir only). Archived
* phases absent from current ROADMAP trigger false W007 in verify.cjs.
*
* 2. phaseVariants() normalization — validate.ts has a phaseVariants() function
* generating padded/unpadded/letter-suffix variants for matching. verify.cjs uses
* only parseInt→padded (drops letter suffix), causing false W006/W007 for
* letter-suffix phases with padding mismatch between ROADMAP and disk.
*
* 3. W006 unchecked-phase variant skip — same phaseVariants() gap causes false W006
* for phases with padding mismatch: ROADMAP says "3B", disk has "03B-foo", but
* verify.cjs padded("3B") = "03" (drops letter) → "03B" on disk not matched.
*
* References:
* - ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md)
* - Issue #6 (open-gsd/gsd-core)
* - PR #154 (issue #4) — precedent for the generator pattern
*/
const { describe, test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { runGsdTools, cleanup } = require('./helpers.cjs');
// ── Fixture helpers ──────────────────────────────────────────────────────────
function mkplanning(base) {
const planningDir = path.join(base, '.planning');
const phasesDir = path.join(planningDir, 'phases');
fs.mkdirSync(phasesDir, { recursive: true });
return { planningDir, phasesDir };
}
function writeProjectMd(planningDir) {
fs.writeFileSync(
path.join(planningDir, 'PROJECT.md'),
'# Project\n\n## What This Is\nTest.\n\n## Core Value\nTest.\n\n## Requirements\nTest.\n',
);
}
function writeStateMd(planningDir, phase = '2') {
fs.writeFileSync(
path.join(planningDir, 'STATE.md'),
`# State\n\n**Current Phase:** ${phase}\n**Status:** In progress\n`,
);
}
function writeConfigJson(planningDir) {
fs.writeFileSync(
path.join(planningDir, 'config.json'),
JSON.stringify({ model_profile: 'balanced' }),
);
}
// ── Drift Item 1: W007 activeDiskPhases ────────────────────────────────────
//
// Project has a shipped milestone archive (milestones/v1.0-phases/) with old phase
// "1" inside, and active phasesDir with phase "2". Current ROADMAP only mentions
// Phase 2 (v1.0 phases were shipped and removed from ROADMAP).
//
// validate.ts: activeDiskPhases = phases from active phasesDir only (not archived).
// "1" is only in diskPhases (via forEachArchivedPhaseToken), not activeDiskPhases.
// W007 iterates activeDiskPhases → "1" never checked → no false W007.
//
// verify.cjs pre-fix: diskPhases = collectDiskPhases() + forEachArchivedPhaseToken().
// diskPhases includes "1" (from old archive). W007 iterates diskPhases → "1" not
// in roadmapPhases → W007 fires for "1". False positive.
describe('Drift item 1 — W007 activeDiskPhases: no false W007 for archived phases', () => {
let tmpDir;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-6-d1-'));
const { planningDir, phasesDir } = mkplanning(tmpDir);
writeProjectMd(planningDir);
writeStateMd(planningDir);
writeConfigJson(planningDir);
fs.writeFileSync(
path.join(planningDir, 'ROADMAP.md'),
[
'# Roadmap',
'',
'- [x] **Phase 2:** API',
'',
'### Phase 2: API',
'',
'## Progress',
'| Phase | Status |',
'|-------|--------|',
'| 2 | Complete |',
].join('\n'),
);
// Active phasesDir: only phase 2
fs.mkdirSync(path.join(phasesDir, '2-api'), { recursive: true });
// Current active milestone archive: v1.1-phases contains phase 2 (in ROADMAP)
fs.mkdirSync(
path.join(planningDir, 'milestones', 'v1.1-phases', '2-api'),
{ recursive: true },
);
// Old shipped milestone archive: v1.0-phases contains phase 1 (no longer in ROADMAP)
// v1.0 sorts before v1.1 so getActiveMilestoneArchiveDir returns v1.1.
// forEachArchivedPhaseToken walks BOTH v1.0 and v1.1, so diskPhases gets "1".
// collectDiskPhases (activeDiskPhases) only uses v1.1 (active archive) — "1" excluded.
fs.mkdirSync(
path.join(planningDir, 'milestones', 'v1.0-phases', '1-foundation'),
{ recursive: true },
);
});
after(() => {
cleanup(tmpDir);
});
test('no W007 for archived phase "1" absent from current ROADMAP', () => {
// validate.ts: activeDiskPhases has only "2" (from active phasesDir + v1.1 archive).
// "1" is in diskPhases (forEachArchivedPhaseToken walks v1.0) but NOT activeDiskPhases.
// W007 iterates activeDiskPhases → "1" never checked → no false W007. Correct.
// verify.cjs pre-fix: diskPhases = collectDiskPhases + forEachArchivedPhaseToken.
// diskPhases includes "1" (from v1.0 archive). W007 iterates diskPhases → "1" not
// in roadmapPhases → W007 fires for "1". False positive.
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
const data = JSON.parse(result.output);
const w007 = (data.warnings ?? []).filter((w) => w.code === 'W007');
// Filter to W007 that mentions only phase "1" (not "1A", "01A", etc.)
const w007Phase1 = w007.filter(
(w) => /\bPhase 1\b/i.test(w.message) && !/\b1[A-Z]\b/i.test(w.message),
);
assert.strictEqual(
w007Phase1.length,
0,
`Expected no W007 for archived phase 1 (v1.0 archive), got: ${JSON.stringify(w007)}`,
);
});
});
// ── Drift Item 2: phaseVariants() normalization ────────────────────────────
//
// ROADMAP has "### Phase 01A:" (zero-padded letter-suffix heading).
// Disk has directory "1A-foo" (unpadded letter-suffix form).
// These should match because phaseVariants("01A") = {"01A", "1A", "01A"}.
//
// validate.ts: diskPhases has "1A". phaseVariants("01A") includes "1A" → match → no W006.
// activeDiskPhases has "1A". phaseVariants("1A") includes "01A" → roadmapPhaseVariants
// has "01A" → match → no W007.
//
// verify.cjs pre-fix:
// W006 loop for "01A": padded = String(parseInt("01A",10)).padStart(2,'0') = "01".
// diskPhases.has("01A")? NO. diskPhases.has("01")? NO. → W006 fires. Bug.
// W007 loop for "1A": unpadded = String(parseInt("1A",10)) = "1".
// roadmapPhases.has("1A")? NO. roadmapPhases.has("1")? NO. → W007 fires. Bug.
describe('Drift item 2 — phaseVariants() normalization: letter-suffix zero-padding mismatch', () => {
let tmpDir;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-6-d2-'));
const { planningDir, phasesDir } = mkplanning(tmpDir);
writeProjectMd(planningDir);
writeStateMd(planningDir);
writeConfigJson(planningDir);
// ROADMAP: Phase 01A (zero-padded + letter suffix)
fs.writeFileSync(
path.join(planningDir, 'ROADMAP.md'),
[
'# Roadmap',
'',
'- [x] **Phase 01A:** Suffix Phase',
'',
'### Phase 01A: Suffix Phase',
'',
'## Progress',
'| Phase | Status |',
'|-------|--------|',
'| 01A | Complete |',
].join('\n'),
);
// Disk: unpadded form "1A-foo"
fs.mkdirSync(path.join(phasesDir, '1A-suffix-phase'), { recursive: true });
});
after(() => {
cleanup(tmpDir);
});
test('no false W006 when ROADMAP says 01A and disk has 1A-... (phaseVariants normalizes)', () => {
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
const data = JSON.parse(result.output);
const w006 = (data.warnings ?? []).filter((w) => w.code === 'W006');
assert.strictEqual(
w006.length,
0,
`Expected no W006 (01A == 1A after normalization), got: ${JSON.stringify(w006)}`,
);
});
test('no false W007 when disk has 1A and ROADMAP says 01A (phaseVariants normalizes)', () => {
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
const data = JSON.parse(result.output);
const w007 = (data.warnings ?? []).filter((w) => w.code === 'W007');
assert.strictEqual(
w007.length,
0,
`Expected no W007 (1A on disk matches 01A in ROADMAP), got: ${JSON.stringify(w007)}`,
);
});
});
// ── Drift Item 3: W006 false positive when disk uses zero-padded letter form ─
//
// ROADMAP has "### Phase 3B:" (unpadded letter-suffix heading).
// Disk has directory "03B-feature" (zero-padded letter-suffix form).
//
// validate.ts:
// diskPhases has "03B". phaseVariants("3B") = {"3B","3B","03B"}.
// existsOnDisk = diskPhases.has("03B") = TRUE → no W006.
// activeDiskPhases has "03B". phaseVariants("03B") = {"03B","3B","03B"}.
// roadmapPhaseVariants has {"3B","3B","03B"} → "03B" found → no W007.
//
// verify.cjs pre-fix:
// W006 for "3B": padded = parseInt("3B")=3 → "03" (drops "B").
// diskPhases.has("3B")? NO. diskPhases.has("03")? NO (dir is "03B" not "03"). → W006 fires.
// W007 for "03B": unpadded = String(parseInt("03B",10)) = "3".
// roadmapPhases.has("03B")? NO. roadmapPhases.has("3")? NO (ROADMAP has "3B" not "3"). → W007.
describe('Drift item 3 — W006 false positive when disk has zero-padded letter form', () => {
let tmpDir;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-6-d3-'));
const { planningDir, phasesDir } = mkplanning(tmpDir);
writeProjectMd(planningDir);
writeStateMd(planningDir, '3B');
writeConfigJson(planningDir);
// ROADMAP: Phase 3B (unpadded in heading)
fs.writeFileSync(
path.join(planningDir, 'ROADMAP.md'),
[
'# Roadmap',
'',
'- [x] **Phase 3B:** Feature Extension',
'',
'### Phase 3B: Feature Extension',
'',
'## Progress',
'| Phase | Status |',
'|-------|--------|',
'| 3B | Complete |',
].join('\n'),
);
// Disk: "03B-feature" (zero-padded letter-suffix form)
fs.mkdirSync(path.join(phasesDir, '03B-feature'), { recursive: true });
});
after(() => {
cleanup(tmpDir);
});
test('no false W006 when ROADMAP says 3B and disk has 03B-... (phaseVariants covers zero-padded)', () => {
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
const data = JSON.parse(result.output);
const w006 = (data.warnings ?? []).filter((w) => w.code === 'W006');
assert.strictEqual(
w006.length,
0,
`Expected no W006 (3B == 03B after normalization), got: ${JSON.stringify(w006)}`,
);
});
test('no false W007 when disk has 03B and ROADMAP says 3B (phaseVariants covers both forms)', () => {
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
const data = JSON.parse(result.output);
const w007 = (data.warnings ?? []).filter((w) => w.code === 'W007');
assert.strictEqual(
w007.length,
0,
`Expected no W007 (03B on disk matches 3B in ROADMAP), got: ${JSON.stringify(w007)}`,
);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-416-archive-dir-null.test.cjs — consolidation epic #1969 (B2 #1971)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-416-archive-dir-null (consolidation epic #1969 B2 #1971)", () => {
'use strict';
/**
* Regression tests for issue #416 (open-gsd/gsd-core).
*
* Bug: getActiveMilestoneArchiveDir falls back to the newest archive directory
* when STATE.md names a milestone that has no matching archive yet, producing
* W007 false positives for phases from a prior (completed) milestone.
*
* Fix: when STATE.md is present and parseable and names a milestone, but no
* milestones/<vX.Y>-phases/ directory matches, return null. The version-sort
* fallback to the newest archive fires only when STATE.md is absent or
* unparseable.
*
* Knuth invariant: the resolver answers one question —
* "what archive directory holds the active milestone's phases?"
* Answer space: <dir> | null.
*/
process.env.GSD_TEST_MODE = '1';
const { describe, test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { runGsdTools, cleanup } = require('./helpers.cjs');
// ── helpers ──────────────────────────────────────────────────────────────────
function mkplanning(base) {
const planningDir = path.join(base, '.planning');
const phasesDir = path.join(planningDir, 'phases');
fs.mkdirSync(phasesDir, { recursive: true });
return planningDir;
}
function writeMinimalRoadmap(planningDir, phases) {
// phases: array of { num, name, checked }
const checkboxes = phases.map(({ num, name, checked }) =>
`- [${checked ? 'x' : ' '}] **Phase ${num}:** ${name}`,
).join('\n');
const headings = phases.map(({ num, name }) =>
`### Phase ${num}: ${name}\n**Goal:** Completed.\n`,
).join('\n');
fs.writeFileSync(
path.join(planningDir, 'ROADMAP.md'),
`# Roadmap\n\n${checkboxes}\n\n${headings}`,
);
}
function writeStateMdMilestone(planningDir, milestone) {
fs.writeFileSync(
path.join(planningDir, 'STATE.md'),
`# State\n\n**milestone:** ${milestone}\n**Current Phase:** 23\n**Status:** In progress\n`,
);
}
function writeProjectMd(planningDir) {
fs.writeFileSync(
path.join(planningDir, 'PROJECT.md'),
'# Project\n\n## What This Is\nTest.\n\n## Core Value\nTest.\n\n## Requirements\nTest.\n',
);
}
function writeConfigJson(planningDir) {
fs.writeFileSync(
path.join(planningDir, 'config.json'),
JSON.stringify({ model_profile: 'balanced' }),
);
}
function mkArchivePhases(planningDir, version, phaseNums) {
// Creates .planning/milestones/<version>-phases/<NN>-phase-name/ dirs
const archiveDir = path.join(planningDir, 'milestones', `${version}-phases`);
for (const num of phaseNums) {
const padded = String(num).padStart(2, '0');
fs.mkdirSync(path.join(archiveDir, `${padded}-phase-${num}`), { recursive: true });
}
return archiveDir;
}
// ─────────────────────────────────────────────────────────────────────────────
// Case 1: STATE.md milestone: v6.0, only v5.0-phases/ on disk
// → resolver returns null, verifier emits zero W007 for phases 17–22
// ─────────────────────────────────────────────────────────────────────────────
describe('bug #416 case 1: STATE.md v6.0 with only v5.0-phases/ on disk → null, no W007', () => {
let tmpDir;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-416-c1-'));
const planningDir = mkplanning(tmpDir);
writeProjectMd(planningDir);
writeConfigJson(planningDir);
// Active milestone is v6.0 — no archive for it yet (phases live in flat phases/)
writeStateMdMilestone(planningDir, 'v6.0');
// v5.0 was the prior completed milestone; its archive exists on disk
mkArchivePhases(planningDir, 'v5.0', [17, 18, 19, 20, 21, 22]);
// ROADMAP reflects only v6.0 phases (v5.0 phases are in a prior milestone,
// not in the current roadmap section)
writeMinimalRoadmap(planningDir, [
{ num: 23, name: 'New Foundation', checked: false },
]);
});
after(() => { cleanup(tmpDir); });
test('validate health emits zero W007 warnings (no prior-milestone phases surfaced)', () => {
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
assert.strictEqual(result.success, true, `validate health failed: ${result.error}`);
const data = JSON.parse(result.output);
const w007 = (data.warnings ?? []).filter((w) => w.code === 'W007');
assert.strictEqual(
w007.length,
0,
`Expected zero W007 — phases 17–22 from v5.0-phases/ must not appear as "active".\nGot: ${JSON.stringify(w007, null, 2)}`,
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Case 2: No STATE.md, multiple archives on disk → version-sort fallback
// returns the highest-versioned archive (existing behavior preserved)
// ─────────────────────────────────────────────────────────────────────────────
describe('bug #416 case 2: no STATE.md + multiple archives → version-sort fallback to newest', () => {
let tmpDir;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-416-c2-'));
const planningDir = mkplanning(tmpDir);
writeProjectMd(planningDir);
writeConfigJson(planningDir);
// No STATE.md — resolver must use the version-sort fallback
// Two archives: v4.0 and v5.0; v5.0 is newer
mkArchivePhases(planningDir, 'v4.0', [10, 11, 12]);
mkArchivePhases(planningDir, 'v5.0', [17, 18, 19]);
// ROADMAP lists v5.0 phases so W007 does not fire
writeMinimalRoadmap(planningDir, [
{ num: 17, name: 'Alpha', checked: true },
{ num: 18, name: 'Beta', checked: true },
{ num: 19, name: 'Gamma', checked: true },
]);
});
after(() => { cleanup(tmpDir); });
test('validate health succeeds and does not emit W007 for v5.0 archive phases', () => {
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
assert.strictEqual(result.success, true, `validate health failed: ${result.error}`);
const data = JSON.parse(result.output);
const w007 = (data.warnings ?? []).filter((w) => w.code === 'W007');
// v5.0 phases (17–19) are in the archive returned by the fallback and
// in the ROADMAP, so no W007 should fire.
assert.strictEqual(
w007.length,
0,
`Expected zero W007 for v5.0 archive phases present in ROADMAP.\nGot: ${JSON.stringify(w007, null, 2)}`,
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Case 3: STATE.md milestone: v5.0, matching v5.0-phases/ exists → returns it
// (regression guard — happy path must not break)
// ─────────────────────────────────────────────────────────────────────────────
describe('bug #416 case 3: STATE.md v5.0 with matching v5.0-phases/ → returns archive dir', () => {
let tmpDir;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-416-c3-'));
const planningDir = mkplanning(tmpDir);
writeProjectMd(planningDir);
writeConfigJson(planningDir);
// STATE.md names v5.0 and a matching archive exists
writeStateMdMilestone(planningDir, 'v5.0');
mkArchivePhases(planningDir, 'v5.0', [17, 18, 19, 20, 21, 22]);
// ROADMAP lists v5.0 phases so W007 does not fire
writeMinimalRoadmap(planningDir, [
{ num: 17, name: 'Alpha', checked: true },
{ num: 18, name: 'Beta', checked: true },
{ num: 19, name: 'Gamma', checked: true },
{ num: 20, name: 'Delta', checked: true },
{ num: 21, name: 'Epsilon', checked: true },
{ num: 22, name: 'Zeta', checked: true },
]);
});
after(() => { cleanup(tmpDir); });
test('validate health emits zero W007 — archive phases are in ROADMAP and active', () => {
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
assert.strictEqual(result.success, true, `validate health failed: ${result.error}`);
const data = JSON.parse(result.output);
const w007 = (data.warnings ?? []).filter((w) => w.code === 'W007');
assert.strictEqual(
w007.length,
0,
`Expected zero W007 for matching v5.0 archive with v5.0 in STATE.md.\nGot: ${JSON.stringify(w007, null, 2)}`,
);
});
});
});
}

View File

@@ -805,3 +805,343 @@ describe('init manager — cross-milestone dependency satisfaction (#2267)', ()
);
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-3584-runtime-slash-emitters.test.cjs — consolidation epic #1969 (B2 #1971)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-3584-runtime-slash-emitters (consolidation epic #1969 B2 #1971)", () => {
/**
* Regression tests for bug #3584 — runtime emitters use the slash formatter.
*
* These tests exercise the actual runtime command handlers via `runGsdTools`
* and assert on the structured payloads they emit/persist. They prove that
* the high-impact emitters identified in the issue (`init.cjs` recommended
* actions, `phase.cjs` ROADMAP persistence, `verify.cjs` remediation hints,
* `milestone.cjs` Operator-Next-Steps persistence, `validate-command-router`
* fracture recommendations) no longer emit the unroutable `/gsd:<cmd>` colon
* form for skills-based runtimes.
*/
'use strict';
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const {
runGsdTools,
createTempProject,
createTempDir,
cleanup,
} = require('./helpers.cjs');
// Helper: assert no string field anywhere in `value` (recursive) contains '/gsd:'.
function assertNoColonForm(value, label) {
const stack = [{ v: value, p: label }];
while (stack.length > 0) {
const { v, p } = stack.pop();
if (typeof v === 'string') {
assert.ok(
!v.includes('/gsd:'),
`${p}: must not contain deprecated /gsd: form, got ${JSON.stringify(v)}`,
);
} else if (Array.isArray(v)) {
v.forEach((item, i) => stack.push({ v: item, p: `${p}[${i}]` }));
} else if (v && typeof v === 'object') {
for (const key of Object.keys(v)) {
stack.push({ v: v[key], p: `${p}.${key}` });
}
}
}
}
// Helper: collect every `command` string under a recommendedActions-style array.
function collectCommandFields(value) {
const out = [];
const stack = [value];
while (stack.length > 0) {
const v = stack.pop();
if (Array.isArray(v)) {
for (const item of v) stack.push(item);
} else if (v && typeof v === 'object') {
if (typeof v.command === 'string') out.push(v.command);
for (const key of Object.keys(v)) stack.push(v[key]);
}
}
return out;
}
describe('bug-3584: init manager recommendedActions emit hyphen form', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
// Minimal ROADMAP + STATE so init manager will run.
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'# Roadmap',
'',
'## Milestone v1',
'',
'### Phase 1: Foundation',
'**Goal:** scaffold',
'**Plans:** 1 plan',
'',
].join('\n'),
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'# State\n\nCurrent Phase: 1\n',
);
});
afterEach(() => cleanup(tmpDir));
test('skills runtime (claude) emits /gsd-<cmd> in recommended_actions[].command', () => {
// Plan-but-not-executed: directory exists with a PLAN.md → execute is recommended.
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
const result = runGsdTools('init manager', tmpDir, { GSD_RUNTIME: 'claude' });
assert.ok(result.success, `init manager failed: ${result.error || result.output}`);
const payload = JSON.parse(result.output);
const commands = collectCommandFields(payload.recommended_actions || []);
assert.ok(commands.length > 0, 'recommended_actions should produce at least one command');
for (const cmd of commands) {
assert.ok(
cmd.startsWith('/gsd-'),
`recommended_actions command must start with /gsd- for skills-based runtimes, got ${cmd}`,
);
assert.ok(
!cmd.includes('/gsd:'),
`recommended_actions command must not contain /gsd: colon form, got ${cmd}`,
);
}
assertNoColonForm(payload, 'init.manager payload');
});
test('codex runtime emits $gsd-<cmd> in recommended_actions[].command', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
const result = runGsdTools('init manager', tmpDir, { GSD_RUNTIME: 'codex' });
assert.ok(result.success, `init manager (codex) failed: ${result.error || result.output}`);
const payload = JSON.parse(result.output);
const commands = collectCommandFields(payload.recommended_actions || []);
assert.ok(commands.length > 0);
for (const cmd of commands) {
assert.ok(
cmd.startsWith('$gsd-'),
`codex recommended_actions command must use $gsd- shell-var form, got ${cmd}`,
);
}
});
test('codex alias runtime emits $gsd-<cmd> in recommended_actions[].command', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
const result = runGsdTools('init manager', tmpDir, { GSD_RUNTIME: 'codex-app' });
assert.ok(result.success, `init manager (codex-app) failed: ${result.error || result.output}`);
const payload = JSON.parse(result.output);
const commands = collectCommandFields(payload.recommended_actions || []);
assert.ok(commands.length > 0);
for (const cmd of commands) {
assert.ok(
cmd.startsWith('$gsd-'),
`codex alias recommended_actions command must use $gsd- shell-var form, got ${cmd}`,
);
}
});
});
describe('bug-3584: phase add persists hyphen form into ROADMAP.md', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n## Milestone v1\n\n',
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'# State\n',
);
});
afterEach(() => cleanup(tmpDir));
test('phase add persists hyphen-form slash command in roadmap get-phase payload', () => {
// 1. Add a phase — the system under test persists the phase entry into ROADMAP.md.
const addResult = runGsdTools(
['phase', 'add', 'Test new feature'],
tmpDir,
{ GSD_RUNTIME: 'claude' },
);
assert.ok(addResult.success, `phase add failed: ${addResult.error || addResult.output}`);
const addPayload = JSON.parse(addResult.output);
assert.ok(addPayload.phase_number, 'phase add must return phase_number');
// 2. Read the persisted section back via the structured roadmap-get-phase
// contract (NOT via readFileSync; the `section` field on the JSON payload
// is the runtime's typed projection of the on-disk ROADMAP content).
const getResult = runGsdTools(
['roadmap', 'get-phase', String(addPayload.phase_number)],
tmpDir,
{ GSD_RUNTIME: 'claude' },
);
assert.ok(getResult.success, `roadmap get-phase failed: ${getResult.error || getResult.output}`);
const getPayload = JSON.parse(getResult.output);
assert.ok(getPayload.found, 'roadmap get-phase must find the newly-added phase');
assert.ok(typeof getPayload.section === 'string', 'roadmap get-phase must return a section field');
// 3. The persisted phase section must use the routable hyphen form.
assert.ok(
!getPayload.section.includes('/gsd:plan-phase'),
`persisted phase section must not contain /gsd:plan-phase, got: ${getPayload.section}`,
);
assert.ok(
getPayload.section.includes('/gsd-plan-phase'),
`persisted phase section must contain /gsd-plan-phase, got: ${getPayload.section}`,
);
});
});
describe('bug-3584: validate health emits hyphen form in fix hints', () => {
test('validate health on a broken project returns hyphen-form fix strings', (t) => {
const tmpDir = createTempDir();
t.after(() => cleanup(tmpDir));
// No .planning directory → E001 fires with a `fix:` string that contains
// the slash-command form.
const result = runGsdTools(
['validate', 'health'],
tmpDir,
{ GSD_RUNTIME: 'claude' },
);
// validate health exits non-zero on broken projects but still emits JSON to stdout.
const stdout = result.output;
let payload;
try {
payload = JSON.parse(stdout);
} catch (e) {
throw new Error(`validate health did not emit parseable JSON: ${stdout.slice(0, 400)}`);
}
const allIssues = []
.concat(payload.errors || [])
.concat(payload.warnings || [])
.concat(payload.info || []);
assert.ok(allIssues.length > 0, 'validate health on a bare tmpdir should report at least one issue');
const fixesWithSlash = allIssues
.map((i) => i.fix)
.filter((f) => typeof f === 'string' && /gsd[-:]/.test(f));
assert.ok(
fixesWithSlash.length > 0,
'at least one fix hint should reference a /gsd- slash command',
);
for (const fix of fixesWithSlash) {
assert.ok(
!fix.includes('/gsd:'),
`validate health fix hint must not contain /gsd: colon form, got ${JSON.stringify(fix)}`,
);
}
assertNoColonForm(payload, 'validate health payload');
});
});
describe('bug-3584: validate context recommendation uses hyphen form', () => {
test('warning/critical recommendations emit /gsd-thread', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
// Critical band: 75% utilization. validate context emits JSON only with --json.
const result = runGsdTools(
['validate', 'context', '--tokens-used', '75000', '--context-window', '100000', '--json'],
tmpDir,
{ GSD_RUNTIME: 'claude' },
);
assert.ok(result.success, `validate context failed: ${result.error || result.output}`);
const payload = JSON.parse(result.output);
assert.ok(payload.recommendation, 'critical utilization should produce a recommendation');
assert.ok(
!payload.recommendation.includes('/gsd:'),
`recommendation must not contain /gsd:, got ${JSON.stringify(payload.recommendation)}`,
);
assert.ok(
payload.recommendation.includes('/gsd-thread'),
`recommendation must reference /gsd-thread, got ${JSON.stringify(payload.recommendation)}`,
);
});
});
describe('bug-3584: validate health uses formatter for codex runtime too', () => {
test('validate health under codex emits $gsd-<cmd> in fix strings (positive assertion)', (t) => {
const tmpDir = createTempDir();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(
['validate', 'health'],
tmpDir,
{ GSD_RUNTIME: 'codex' },
);
let payload;
try {
payload = JSON.parse(result.output);
} catch (e) {
throw new Error(`validate health (codex) did not emit JSON: ${result.output.slice(0, 400)}`);
}
const allIssues = []
.concat(payload.errors || [])
.concat(payload.warnings || [])
.concat(payload.info || []);
// Collect fixes that mention any gsd slash-command form so we can lock
// both the absence of legacy forms AND the presence of the codex shape.
const fixesWithGsdRef = allIssues
.map((i) => i.fix)
.filter((f) => typeof f === 'string' && /(?:\$|\/)gsd[-:]/.test(f));
assert.ok(
fixesWithGsdRef.length > 0,
'validate health on a bare tmpdir must produce at least one fix hint referencing a gsd command',
);
for (const fix of fixesWithGsdRef) {
assert.ok(
fix.includes('$gsd-'),
`codex validate health fix must use shell-var $gsd- form, got ${JSON.stringify(fix)}`,
);
assert.ok(
!fix.includes('/gsd:'),
`codex validate health fix must not contain /gsd: colon form, got ${JSON.stringify(fix)}`,
);
assert.ok(
!fix.includes('/gsd-'),
`codex validate health fix must not contain /gsd- (skills) form, got ${JSON.stringify(fix)}`,
);
}
});
});
});
}

View File

@@ -799,3 +799,105 @@ describe('#1871 — milestone complete archives phase dirs by default', () => {
assert.ok(fs.existsSync(phaseDir), 'phase dir should remain in place with --no-archive-phases');
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-978-milestone-complete-force.test.cjs — consolidation epic #1969 (B2 #1971)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-978-milestone-complete-force (consolidation epic #1969 B2 #1971)", () => {
'use strict';
/**
* Regression test for bug #978: `gsd-tools milestone complete --force` was a
* dead flag. The milestone source (src/milestone.cts) has a guard that checks
* `options.force` and tells users to "Re-run with --force to override", but the
* CLI dispatcher (gsd-core/bin/gsd-tools.cjs) never parsed `--force` and never
* passed it into the options object. So `options.force` was always `undefined`
* and the guard could never be overridden regardless of what the user typed.
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
/**
* Build a fixture where the guard will fire:
* - STATE.md has `milestone: <version>` so the guard's version-match check is
* satisfied.
* - ROADMAP.md lists a `### Phase 2: Real Work` heading for that milestone, but
* there is NO on-disk phase directory for it.
*
* This guarantees "unstarted phase" detection without touching any real phases.
* NOTE: the unstarted phase must be a REAL phase number — Phase 0 and Phase 999
* are backlog/pre-milestone sentinels that are intentionally excluded from this
* guard (#1580), so they would not fire it.
*/
function makeGuardFixture(tmpDir, version) {
// STATE.md with frontmatter milestone field matching the version
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`---\nmilestone: ${version}\n---\n# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`,
);
// ROADMAP.md — the heading must include the version so getMilestonePhaseFilter
// does not return missingExplicitVersion. Phase 2 has no on-disk dir.
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap ${version}\n\n### Phase 2: Real Work\n**Goal:** Not started\n`,
);
}
describe('bug-978: milestone complete --force overrides unstarted-phase guard', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject('gsd-bug-978-');
});
afterEach(() => {
cleanup(tmpDir);
});
test('without --force the guard fires and emits the documented error message', () => {
makeGuardFixture(tmpDir, 'v1.0');
const result = runGsdTools(
['milestone', 'complete', 'v1.0', '--name', 'Regression Test'],
tmpDir,
);
assert.strictEqual(result.success, false, 'command should fail without --force');
assert.ok(
result.error.includes('Re-run with --force to override'),
`expected guard error message; got: ${result.error}`,
);
});
test('with --force the guard is bypassed and the command succeeds', () => {
makeGuardFixture(tmpDir, 'v1.0');
const result = runGsdTools(
['milestone', 'complete', 'v1.0', '--name', 'Regression Test', '--force'],
tmpDir,
);
assert.ok(
result.success,
`command should succeed with --force but failed: ${result.error}`,
);
const output = JSON.parse(result.output);
assert.strictEqual(output.version, 'v1.0');
// Milestone entry should have been created even though phase 2 has no dir
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'MILESTONES.md')),
'MILESTONES.md should have been created',
);
});
});
});
}

View File

@@ -421,3 +421,138 @@ describe('phase-command-router — integration: real hub + CJS phase handler', (
assert.deepEqual(calls[0].descriptions, ['Alpha', 'Beta']);
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/fix-1437-phase-list-plans.test.cjs — consolidation epic #1969 (B2 #1971)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:fix-1437-phase-list-plans (consolidation epic #1969 B2 #1971)", () => {
'use strict';
/**
* Regression tests for `gsd-tools query phase.list-plans <N>` (#1437).
*
* Prior to this fix, `phase.list-plans` was not registered in the
* phase-command-router, so any invocation produced:
* "Error: Unknown phase subcommand. Available: uat-passed, next-decimal, ..."
*
* These tests exercise the full dispatch path:
* gsd-tools → phase-command-router → phase.cmdPhaseListPlans
*/
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
// ─── Fixture helpers ──────────────────────────────────────────────────────────
function setupProject(phaseSlug = '01-feature') {
const tmpDir = createTempProject();
// Minimal ROADMAP so findPhaseInternal can resolve the phase directory
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'# Roadmap',
'',
'- [ ] Phase 1: Feature',
'',
'### Phase 1: Feature',
'**Goal:** Build feature',
'**Plans:** 1 plans',
'',
].join('\n'),
);
const phaseDir = path.join(tmpDir, '.planning', 'phases', phaseSlug);
fs.mkdirSync(phaseDir, { recursive: true });
return { tmpDir, phaseDir };
}
function touch(dir, ...files) {
for (const f of files) {
fs.writeFileSync(path.join(dir, f), '');
}
}
// ─── Tests ────────────────────────────────────────────────────────────────────
let tmpDir;
let phaseDir;
beforeEach(() => {
const proj = setupProject('01-feature');
tmpDir = proj.tmpDir;
phaseDir = proj.phaseDir;
});
afterEach(() => {
cleanup(tmpDir);
});
describe('bug-1437 — phase.list-plans is wired in gsd-tools', () => {
test('command no longer returns Unknown phase subcommand error', () => {
touch(phaseDir, '01-01-PLAN.md');
const result = runGsdTools(['query', 'phase.list-plans', '1'], tmpDir);
// Previously this would fail with "Unknown phase subcommand"
assert.ok(result.success, `Command failed: ${result.error}\nOutput: ${result.output}`);
assert.ok(!result.error || !result.error.includes('Unknown phase subcommand'),
`got unexpected error: ${result.error}`);
});
test('returns JSON with plan_count and plans array when plans exist', () => {
touch(phaseDir, '01-01-PLAN.md', '01-02-PLAN.md');
const result = runGsdTools(['query', 'phase.list-plans', '1', '--raw'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}\nOutput: ${result.output}`);
const data = JSON.parse(result.output);
assert.equal(data.plan_count, 2, 'plan_count should be 2');
assert.equal(data.has_plans, true, 'has_plans should be true');
assert.ok(Array.isArray(data.plans), 'plans should be an array');
assert.equal(data.plans.length, 2, 'plans array should have 2 entries');
});
test('returns plan_count 0 and empty plans array when phase has no plan files', () => {
// Phase directory exists but has no *-PLAN.md files
touch(phaseDir, 'CONTEXT.md');
const result = runGsdTools(['query', 'phase.list-plans', '1', '--raw'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}\nOutput: ${result.output}`);
const data = JSON.parse(result.output);
assert.equal(data.plan_count, 0);
assert.equal(data.has_plans, false);
assert.deepEqual(data.plans, []);
});
test('returns has_plans false when phase number is not found', () => {
// Phase 99 does not exist in the fixture
const result = runGsdTools(['query', 'phase.list-plans', '99', '--raw'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}\nOutput: ${result.output}`);
const data = JSON.parse(result.output);
assert.equal(data.has_plans, false);
assert.equal(data.plan_count, 0);
});
test('plan paths are relative to project root and posix-style', () => {
touch(phaseDir, '01-01-PLAN.md');
const result = runGsdTools(['query', 'phase.list-plans', '1', '--raw'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}\nOutput: ${result.output}`);
const data = JSON.parse(result.output);
assert.equal(data.plans.length, 1);
// Paths must be forward-slash separated (posix) and relative (not absolute)
const planPath = data.plans[0];
assert.ok(!path.isAbsolute(planPath), `expected relative path, got: ${planPath}`);
assert.ok(!planPath.includes('\\'), `expected posix path, got: ${planPath}`);
assert.ok(planPath.includes('01-01-PLAN.md'), `expected plan filename in path: ${planPath}`);
});
test('dotted form phase.list-plans (without query prefix) also works', () => {
touch(phaseDir, '01-01-PLAN.md');
const result = runGsdTools(['phase.list-plans', '1', '--raw'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}\nOutput: ${result.output}`);
const data = JSON.parse(result.output);
assert.equal(data.plan_count, 1);
});
});
});
}

View File

@@ -5943,3 +5943,959 @@ describe('bug #1229: phase.add must count bullet-only phases to avoid number col
);
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/247-phase-uat-passed.test.cjs — consolidation epic #1969 (B2 #1971)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:247-phase-uat-passed (consolidation epic #1969 B2 #1971)", () => {
'use strict';
/**
* Integration tests for `phase uat-passed <N>` CLI command.
* Issue #247 — phase uat-passed predicate
*
* Tests the full dispatch path: gsd-tools → phase-command-router → phase.cmdPhaseUatPassed
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
// ─── Helpers ──────────────────────────────────────────────────────────────────
/**
* Set up a minimal project with a phase directory and ROADMAP so that
* findPhaseInternal(cwd, phaseNum) can resolve it.
* Returns { tmpDir, phaseDir }.
*/
function setupProject(phaseSlug = '01-feature') {
const tmpDir = createTempProject();
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'# Roadmap',
'',
'- [ ] Phase 1: Feature',
'',
'### Phase 1: Feature',
'**Goal:** Build feature',
'**Plans:** 1 plans',
'',
].join('\n'),
);
const phaseDir = path.join(tmpDir, '.planning', 'phases', phaseSlug);
fs.mkdirSync(phaseDir, { recursive: true });
return { tmpDir, phaseDir };
}
function writeUatFile(phaseDir, filename, content) {
fs.writeFileSync(path.join(phaseDir, filename), content, 'utf-8');
}
function setMtime(filePath, time) {
fs.utimesSync(filePath, time, time);
}
function makePassingUat() {
return [
'---',
'status: passed',
'---',
'',
'# UAT Results',
'',
'### 1. Login works',
'expected: User logs in successfully',
'result: passed',
'',
].join('\n');
}
function makePendingUat() {
return [
'---',
'status: partial',
'---',
'',
'# UAT Results',
'',
'### 1. Login works',
'expected: User logs in successfully',
'result: passed',
'',
'### 2. Logout works',
'expected: User logs out successfully',
'result: pending',
'',
].join('\n');
}
function makeFencedFalsePositiveUat() {
// Only "result: passed" lines are inside a fenced block.
// The real test has result: pending → should evaluate to passed:false.
return [
'---',
'status: partial',
'---',
'',
'# UAT Results',
'',
'## Example (do not run)',
'```',
'### 1. Test',
'expected: Example',
'result: passed',
'```',
'',
'### 1. Real Test',
'expected: The thing works',
'result: pending',
'',
].join('\n');
}
// ─── Basic pass/fail cases ─────────────────────────────────────────────────────
describe('phase uat-passed — basic pass/fail', () => {
let tmpDir;
let phaseDir;
beforeEach(() => {
({ tmpDir, phaseDir } = setupProject('01-feature'));
});
afterEach(() => {
cleanup(tmpDir);
});
test('passing UAT → passed:true with correct JSON shape', () => {
writeUatFile(phaseDir, 'feature-UAT.md', makePassingUat());
const result = runGsdTools('phase uat-passed 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}\nOutput: ${result.output}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.passed, true);
assert.strictEqual(out.phase, '1');
assert.ok(Array.isArray(out.uat_files), 'uat_files must be an array');
assert.ok(Array.isArray(out.verification_files), 'verification_files must be an array');
assert.ok(Array.isArray(out.checks), 'checks must be an array');
assert.ok(Array.isArray(out.blockers), 'blockers must be an array');
assert.ok(out.policy && typeof out.policy.require_verification === 'boolean',
'policy.require_verification must be a boolean');
assert.strictEqual(typeof out.no_uat_artifacts, 'boolean', 'no_uat_artifacts must be a boolean');
assert.strictEqual(out.no_uat_artifacts, false, 'no_uat_artifacts must be false when checks exist');
assert.strictEqual(out.blockers.length, 0);
});
test('pending UAT → passed:false', () => {
writeUatFile(phaseDir, 'feature-UAT.md', makePendingUat());
const result = runGsdTools('phase uat-passed 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.passed, false);
assert.strictEqual(out.phase, '1');
assert.ok(out.blockers.length > 0, 'Should have blockers for pending test');
});
test('false-positive only (fenced block) → passed:false', () => {
writeUatFile(phaseDir, 'feature-UAT.md', makeFencedFalsePositiveUat());
const result = runGsdTools('phase uat-passed 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.passed, false,
'result:passed inside a fenced block must not flip the predicate to passed');
});
test('no UAT files → passed:false + no_uat_artifacts:true (fail-closed, no vacuous pass)', () => {
// Phase directory exists but has no UAT files — fail-closed: absence is NOT a pass
const result = runGsdTools('phase uat-passed 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.passed, false,
'Phase with no UAT files must NOT vacuously pass — fail-closed predicate');
assert.strictEqual(out.no_uat_artifacts, true,
'no_uat_artifacts must be true when no UAT items found');
assert.deepStrictEqual(out.uat_files, []);
});
});
// ─── --require-verification flag ──────────────────────────────────────────────
describe('phase uat-passed — --require-verification flag', () => {
let tmpDir;
let phaseDir;
beforeEach(() => {
({ tmpDir, phaseDir } = setupProject('01-feature'));
});
afterEach(() => {
cleanup(tmpDir);
});
test('--require-verification with no verification file → passed:false', () => {
writeUatFile(phaseDir, 'feature-UAT.md', makePassingUat());
const result = runGsdTools('phase uat-passed 1 --require-verification', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.passed, false,
'require-verification with no verification file should fail');
assert.strictEqual(out.policy.require_verification, true);
assert.ok(out.blockers.some(b => /verification required/i.test(b)),
`Expected verification-required blocker, got: ${JSON.stringify(out.blockers)}`);
});
test('--require-verification with passing verification → passed:true', () => {
writeUatFile(phaseDir, 'feature-UAT.md', makePassingUat());
writeUatFile(phaseDir, 'feature-VERIFICATION.md', '---\nstatus: passed\n---\n\nVerified OK.');
const result = runGsdTools('phase uat-passed 1 --require-verification', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.passed, true);
assert.strictEqual(out.policy.require_verification, true);
});
test('--require-verification with stale passed verification → passed:false', () => {
writeUatFile(phaseDir, 'feature-UAT.md', makePassingUat());
const verificationPath = path.join(phaseDir, 'feature-VERIFICATION.md');
const summaryPath = path.join(phaseDir, 'feature-SUMMARY.md');
writeUatFile(phaseDir, 'feature-VERIFICATION.md', '---\nstatus: passed\n---\n\nVerified OK.');
writeUatFile(phaseDir, 'feature-SUMMARY.md', '# Summary\n\nImplementation changed after verification.\n');
const now = new Date();
setMtime(verificationPath, new Date(now.getTime() - 60_000));
setMtime(summaryPath, now);
const result = runGsdTools('phase uat-passed 1 --require-verification', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.passed, false);
assert.ok(
out.blockers.some(b => /verification status=stale/i.test(b)),
`Expected stale-verification blocker, got: ${JSON.stringify(out.blockers)}`,
);
});
test('--require-verification with non-canonical complete verification → passed:false', () => {
writeUatFile(phaseDir, 'feature-UAT.md', makePassingUat());
writeUatFile(phaseDir, 'feature-VERIFICATION.md', '---\nstatus: complete\n---\n\nLegacy OK.');
const result = runGsdTools('phase uat-passed 1 --require-verification', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.passed, false);
assert.ok(out.blockers.some(b => /verification required/i.test(b)),
`Expected verification-required blocker, got: ${JSON.stringify(out.blockers)}`);
});
});
// ─── Error cases ──────────────────────────────────────────────────────────────
describe('phase uat-passed — error cases', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
// Write a minimal ROADMAP so phase 1 exists
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'# Roadmap',
'',
'### Phase 1: Feature',
'**Goal:** Build feature',
'',
].join('\n'),
);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-feature'), { recursive: true });
});
afterEach(() => {
cleanup(tmpDir);
});
test('missing phase number → error message', () => {
const result = runGsdTools('phase uat-passed', tmpDir);
assert.ok(!result.success, 'Should fail with no phase number');
assert.ok(
result.error.includes('phase number required') ||
result.error.includes('Available:'),
`Expected phase-number-required error, got: ${result.error}`,
);
});
test('unknown phase number → error message', () => {
const result = runGsdTools('phase uat-passed 99', tmpDir);
assert.ok(!result.success, 'Should fail for unknown phase');
assert.ok(
result.error.includes('not found') || result.error.includes('99'),
`Expected not-found error, got: ${result.error}`,
);
});
test('unknown flag (typo --require-verifcation) → InvalidArgs error, not silent pass', () => {
const result = runGsdTools('phase uat-passed 1 --require-verifcation', tmpDir);
assert.ok(!result.success,
'Unknown flag must cause an error, not silently pass');
assert.ok(
result.error.includes('--require-verifcation') ||
result.error.includes('does not support') ||
result.error.includes('invalid'),
`Expected unknown-flag error, got: ${result.error}`,
);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-2769-requirements-header-variants.test.cjs — consolidation epic #1969 (B2 #1971)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-2769-requirements-header-variants (consolidation epic #1969 B2 #1971)", () => {
/**
* Regression tests for issue #2769
*
* The Requirements header in ROADMAP.md phase blocks renders identically in
* markdown for three textually distinct forms:
*
* **Requirements:** colon INSIDE bold delimiters
* **Requirements**: colon OUTSIDE bold delimiters
* **Requirements** : space-then-colon outside bold
*
* Two parsers in the codebase used opposing strict regexes — one only
* matched the outside-colon form (init.cjs / init.ts), the other only the
* inside-colon form (phase.cjs `cmdPhaseComplete` REQUIREMENTS.md
* traceability sweep). Both must accept all three variants so phase
* metadata propagation is robust to authoring style.
*
* Tests for the init query side live in `tests/init.test.cjs` (parameterized
* over the three variants). This file exercises the inverse bug in
* `phase complete`: the REQUIREMENTS.md checkbox must flip when ROADMAP
* uses the outside-colon form, which previously was silently skipped.
*/
'use strict';
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
describe('bug #2769: phase complete ticks REQUIREMENTS.md across header variants', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
['---', 'current_phase: 1', 'status: executing', '---', '# State', ''].join('\n'),
);
});
afterEach(() => {
cleanup(tmpDir);
});
const headerVariants = [
{ name: 'colon inside bold (**Requirements:**)', header: '**Requirements:** REQ-001' },
{ name: 'colon outside bold (**Requirements**:)', header: '**Requirements**: REQ-001' },
{ name: 'space before colon (**Requirements** :)', header: '**Requirements** : REQ-001' },
];
for (const variant of headerVariants) {
test(`flips REQ-001 checkbox in REQUIREMENTS.md when ROADMAP uses ${variant.name}`, () => {
const phasesDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
fs.mkdirSync(phasesDir, { recursive: true });
fs.writeFileSync(
path.join(phasesDir, '01-1-PLAN.md'),
['---', 'phase: 1', 'plan: 1', '---', '# Plan 1', ''].join('\n'),
);
fs.writeFileSync(
path.join(phasesDir, '01-1-SUMMARY.md'),
['---', 'status: complete', '---', '# Summary', 'Done.'].join('\n'),
);
fs.writeFileSync(
path.join(phasesDir, '01-VERIFICATION.md'),
['---', 'status: passed', 'score: "1/1"', '---', '# Verification', 'Passed.'].join('\n'),
);
const roadmap = [
'# Roadmap',
'',
'### Phase 1: Foundation',
'',
'**Goal:** Build core',
variant.header,
'**Plans:** 1 plans',
'',
'Plans:',
'- [x] 01-1-PLAN.md',
'',
'| Phase | Plans | Status | Completed |',
'|-------|-------|--------|-----------|',
'| 1. Foundation | 0/1 | Pending | - |',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
const requirements = [
'# Requirements',
'',
'## Functional Requirements',
'',
'- [ ] **REQ-001**: Core data model',
'',
'## Traceability',
'',
'| REQ-ID | Phase | Status |',
'|--------|-------|--------|',
'| REQ-001 | 1 | Pending |',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), requirements);
const result = runGsdTools(['phase', 'complete', '1'], tmpDir);
assert.ok(result.success, `phase complete failed: ${result.error}`);
const updated = fs.readFileSync(
path.join(tmpDir, '.planning', 'REQUIREMENTS.md'),
'utf-8',
);
assert.match(
updated,
/-\s*\[x\]\s*\*\*REQ-001\*\*/,
`REQ-001 checkbox must be flipped to [x] when ROADMAP header is "${variant.header}". Got:\n${updated}`,
);
assert.match(
updated,
/\|\s*REQ-001\s*\|\s*1\s*\|\s*Complete\s*\|/,
`Traceability row for REQ-001 must be marked Complete. Got:\n${updated}`,
);
});
}
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-3537-padded-id-against-unpadded-roadmap.test.cjs — consolidation epic #1969 (B2 #1971)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-3537-padded-id-against-unpadded-roadmap (consolidation epic #1969 B2 #1971)", () => {
/**
* Regression tests for bug #3537
*
* Phase state verbs must match a canonical phase id against ROADMAP.md prose
* regardless of zero-padding on either side: the skills pass the padded form
* (`02.7`) after resolving the phase directory, but human-authored ROADMAP
* prose is conventionally un-padded (`### Phase 2.7:`, `- [ ] **Phase 2.7:**`).
*
* v1.42.1 added `phaseMarkdownRegexSource()` which renders `0*<integer><...>`
* — padding-tolerant on both sides — but wired it into only 1 of 8 call sites.
* The other 7 used raw `escapeRegex(phaseNum)` or `0*${escapeRegex(...)}`
* (tolerated extra padding, not missing), so passing the padded form silently
* no-op'd and the verbs returned success while ROADMAP.md was unchanged.
*
* Parity assertion (per CONTEXT.md DEFECT.GENERATIVE-FIX): for each verb,
* running with the padded form must produce the same ROADMAP.md as running
* with the un-padded form against an identical fixture. Per-site fixes
* without a parity test let the next call-site drift back undetected.
*/
'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { execFileSync } = require('node:child_process');
const { cleanup } = require('./helpers.cjs');
const gsdTools = path.resolve(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
function run(args, cwd) {
try {
return {
stdout: execFileSync('node', [gsdTools, ...args], {
cwd,
timeout: 15000,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
}),
ok: true,
};
} catch (e) {
return {
stdout: (e.stdout && e.stdout.toString()) || '',
stderr: (e.stderr && e.stderr.toString()) || '',
ok: false,
code: e.status,
};
}
}
/**
* Build a planning fixture with project_code='CK', padded phase directory
* (`CK-02.7-meta-lead-ads/`), and un-padded ROADMAP prose (`Phase 2.7`).
* This mirrors the reporter's environment in #3537 exactly.
*/
function setupFixture(tmpDir, opts = {}) {
const {
projectCode = 'CK',
paddedId = '02.7',
unpaddedId = '2.7',
extraPhases = [],
} = opts;
const planningDir = path.join(tmpDir, '.planning');
fs.mkdirSync(planningDir, { recursive: true });
fs.writeFileSync(
path.join(planningDir, 'config.json'),
JSON.stringify({ project_code: projectCode })
);
fs.writeFileSync(
path.join(planningDir, 'STATE.md'),
`---\ncurrent_phase: ${unpaddedId}\nstatus: executing\n---\n# State\n`
);
// Padded phase directory with one plan + matching summary so the phase
// is "complete" for phase-complete and update-plan-progress verbs.
const phaseDirName = `${projectCode}-${paddedId}-meta-lead-ads`;
const phaseDir = path.join(planningDir, 'phases', phaseDirName);
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(
path.join(phaseDir, `${paddedId}-01-PLAN.md`),
`---\nphase: ${unpaddedId}\nplan: 1\nwave: 1\n---\n# Plan 1\n`
);
fs.writeFileSync(
path.join(phaseDir, `${paddedId}-01-SUMMARY.md`),
'---\nstatus: complete\n---\n# Summary\nDone.'
);
fs.writeFileSync(
path.join(phaseDir, `${paddedId}-VERIFICATION.md`),
'---\nstatus: passed\nscore: "1/1"\n---\n# Verification\nPassed.\n'
);
const extra = extraPhases
.map((p) => `- [ ] **Phase ${p.id}: ${p.name}**`)
.join('\n');
const roadmap = [
'# Roadmap',
'',
'## v1.0 Milestone',
'',
`- [ ] **Phase ${unpaddedId}: Meta Lead Ads**`,
extra,
'',
'## Progress',
'',
'| Phase | Plans | Status | Completed |',
'|-------|-------|--------|-----------|',
`| ${unpaddedId} Meta Lead Ads | 0/1 | Planned | - |`,
'',
`### Phase ${unpaddedId}: Meta Lead Ads`,
'',
'**Goal:** ship the thing',
'**Plans:** 0 plans',
'',
'Plans:',
`- [ ] ${paddedId}-01-PLAN.md`,
'',
...extraPhases.flatMap((p) => [
`### Phase ${p.id}: ${p.name}`,
'',
'**Goal:** stub',
'**Plans:** 0 plans',
'',
'Plans:',
`- [ ] ${p.id}-01-PLAN.md`,
'',
]),
]
.filter((l) => l !== '')
.join('\n') + '\n';
fs.writeFileSync(path.join(planningDir, 'ROADMAP.md'), roadmap);
return {
planningDir,
roadmapPath: path.join(planningDir, 'ROADMAP.md'),
phaseDir,
};
}
/**
* Run a verb in two parallel fixtures — one passing the padded form, one
* passing the un-padded form — then compare the resulting ROADMAP.md bytes.
* Any divergence means the verb's regex did not tolerate padding on at least
* one side.
*/
function expectParity({ verbWithPadded, verbWithUnpadded, fixtureOpts }) {
const tmpA = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3537-A-'));
const tmpB = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3537-B-'));
try {
const a = setupFixture(tmpA, fixtureOpts);
const b = setupFixture(tmpB, fixtureOpts);
const ra = verbWithPadded(tmpA);
const rb = verbWithUnpadded(tmpB);
const aRoadmap = fs.readFileSync(a.roadmapPath, 'utf-8');
const bRoadmap = fs.readFileSync(b.roadmapPath, 'utf-8');
return { aRoadmap, bRoadmap, ra, rb };
} finally {
cleanup(tmpA);
cleanup(tmpB);
}
}
describe('bug #3537: phase verbs accept padded ids against un-padded ROADMAP prose', () => {
test('phase complete: padded 02.7 and un-padded 2.7 produce identical ROADMAP', () => {
const { aRoadmap, bRoadmap } = expectParity({
fixtureOpts: {},
verbWithPadded: (cwd) => run(['phase', 'complete', '02.7'], cwd),
verbWithUnpadded: (cwd) => run(['phase', 'complete', '2.7'], cwd),
});
assert.equal(
aRoadmap,
bRoadmap,
'padded `02.7` must mutate ROADMAP identically to un-padded `2.7`'
);
// And the canonical mutation must have actually happened (otherwise
// both forms could be silently no-op'ing and still produce identical
// output — a vacuous parity pass).
assert.match(
aRoadmap,
/- \[x\] \*\*Phase 2\.7:/,
'overview checkbox should be flipped under both invocations'
);
});
test('roadmap get-phase: padded 02.7 returns the same section as un-padded 2.7', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3537-get-'));
try {
setupFixture(tmp, {});
const padded = run(['roadmap', 'get-phase', '02.7', '--raw'], tmp);
const unpadded = run(['roadmap', 'get-phase', '2.7', '--raw'], tmp);
assert.equal(
padded.stdout,
unpadded.stdout,
'padded and un-padded ids must return identical sections'
);
// Non-vacuous guard: both forms must have actually returned a section
// (the bug we're fixing was that the padded form returned an empty
// string while reporting success).
assert.ok(
padded.stdout.trim().length > 0,
'verb must return non-empty section under both invocations'
);
} finally {
cleanup(tmp);
}
});
test('phase next-decimal: padded 02 finds decimals in un-padded ROADMAP', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3537-nd-'));
try {
setupFixture(tmp, {
paddedId: '02.7',
unpaddedId: '2.7',
});
// Padded base `02` must discover the existing decimal `2.7` from the
// un-padded heading and propose `2.8` (or higher) as next.
const padded = run(['phase', 'next-decimal', '02', '--raw'], tmp);
const unpadded = run(['phase', 'next-decimal', '2', '--raw'], tmp);
assert.equal(
padded.stdout,
unpadded.stdout,
'next-decimal must produce identical JSON for padded and un-padded base'
);
// Sanity: the existing 2.7 must be reflected. If the prose-scan regex
// silently failed to match `Phase 2.7`, the result would skip 2.7 and
// wrongly propose 2.1 as next. `phase next-decimal --raw` emits the
// next id as plain text (`02.8`), so trimmed string equality is the
// typed assertion shape (no raw-text regex matching — lint policy).
const nextDecimalPadded = padded.stdout.trim();
assert.notEqual(
nextDecimalPadded,
'02.1',
'must not propose 02.1 when 2.7 already exists in ROADMAP'
);
assert.notEqual(
nextDecimalPadded,
'2.1',
'must not propose 2.1 when 2.7 already exists in ROADMAP'
);
} finally {
cleanup(tmp);
}
});
test('phase insert: padded base 02 finds anchor in un-padded ROADMAP', () => {
const tmpA = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3537-ins-A-'));
const tmpB = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3537-ins-B-'));
try {
// Use a phase 2 (no decimal) base so insert proposes 2.1.
const optsA = {
paddedId: '02',
unpaddedId: '2',
};
setupFixture(tmpA, optsA);
setupFixture(tmpB, optsA);
const padded = run(['phase', 'insert', '02', 'urgent extension'], tmpA);
const unpadded = run(['phase', 'insert', '2', 'urgent extension'], tmpB);
// Both invocations should succeed (exit 0) — passing the padded base
// against un-padded prose used to error "Phase 02 not found".
assert.ok(
padded.ok,
`padded form must succeed, got code=${padded.code}, stderr=${padded.stderr}`
);
assert.ok(
unpadded.ok,
`un-padded form must succeed, got code=${unpadded.code}`
);
const aRoadmap = fs.readFileSync(
path.join(tmpA, '.planning', 'ROADMAP.md'),
'utf-8'
);
// The new header may be rendered as `Phase 02.1` or `Phase 2.1`
// (normalizePhaseName pads to 2 digits today; that is pre-existing
// behavior, not the subject of #3537). The critical assertion for
// this verb is "padded form found the anchor and the insertion
// happened" — full byte-parity is gated by an unrelated `Depends on:
// Phase ${afterPhase}` echo bug that lies outside #3537's scope.
assert.match(
aRoadmap,
/### Phase 0?2\.1: urgent extension/,
'padded form must insert the new decimal phase header'
);
// Reference `tmpB` to ensure cleanup runs and keep it alive in the
// closure — also a smoke-check that the un-padded sibling did not
// crash mid-run.
assert.ok(fs.existsSync(path.join(tmpB, '.planning', 'ROADMAP.md')));
} finally {
cleanup(tmpA);
cleanup(tmpB);
}
});
test('roadmap annotate-dependencies: padded 02.7 finds phase section', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3537-ann-'));
try {
const { roadmapPath } = setupFixture(tmp, {});
const before = fs.readFileSync(roadmapPath, 'utf-8');
const padded = run(
['roadmap', 'annotate-dependencies', '02.7'],
tmp
);
assert.ok(
padded.ok,
`padded form must succeed, got code=${padded.code}, stderr=${padded.stderr}`
);
const after = fs.readFileSync(roadmapPath, 'utf-8');
// The annotation may be a no-op if there's only one wave and no
// cross-cutting truths, but the verb must have reached the phase
// section. Confirm by running parity against un-padded form on a
// separate fixture and asserting equality.
const tmp2 = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3537-ann2-'));
try {
const { roadmapPath: rp2 } = setupFixture(tmp2, {});
run(['roadmap', 'annotate-dependencies', '2.7'], tmp2);
const unpadded = fs.readFileSync(rp2, 'utf-8');
assert.equal(
after,
unpadded,
'annotate-dependencies must produce identical output for padded and un-padded ids'
);
// And the verb must not have silently destroyed the file (sanity).
assert.match(after, /### Phase 2\.7:/, 'phase header must survive');
// Reference `before` to keep it from being dead-binding-flagged
// and to assert the run did not corrupt the rest of the file.
assert.ok(before.length > 0);
} finally {
cleanup(tmp2);
}
} finally {
cleanup(tmp);
}
});
test('roadmap update-plan-progress: control case — already wired in 1.42.1', () => {
// This is the one site already using phaseMarkdownRegexSource. Including
// it as a control proves the parity assertion is a meaningful signal
// (this test should pass on main, while the others fail).
const { aRoadmap, bRoadmap } = expectParity({
fixtureOpts: {},
verbWithPadded: (cwd) =>
run(['roadmap', 'update-plan-progress', '02.7'], cwd),
verbWithUnpadded: (cwd) =>
run(['roadmap', 'update-plan-progress', '2.7'], cwd),
});
assert.equal(
aRoadmap,
bRoadmap,
'control verb must already produce identical output (wired in 1.42.1)'
);
assert.match(
aRoadmap,
/- \[x\] \*\*Phase 2\.7:/,
'control verb must flip checkbox under both invocations'
);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-2268-parallel-discuss.test.cjs — consolidation epic #1969 (B2 #1971)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-2268-parallel-discuss (consolidation epic #1969 B2 #1971)", () => {
/**
* Regression test for bug #2268
*
* cmdInitProgress used a sliding-window pattern that set is_next_to_discuss
* only on the FIRST undiscussed phase. Multiple independent undiscussed phases
* could not be discussed in parallel — the manager only ever recommended one
* discuss action at a time.
*
* Fix: mark ALL undiscussed phases as is_next_to_discuss = true so the user
* can pick any of them.
*/
'use strict';
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
function writeRoadmap(tmpDir, phases) {
const sections = phases.map(p => {
let section = `### Phase ${p.number}: ${p.name}\n\n**Goal:** Do the thing\n`;
return section;
}).join('\n');
const checklist = phases.map(p => {
const mark = p.complete ? 'x' : ' ';
return `- [${mark}] **Phase ${p.number}: ${p.name}**`;
}).join('\n');
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n## Progress\n\n${checklist}\n\n${sections}`
);
}
function writeState(tmpDir) {
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '---\nstatus: active\n---\n# State\n');
}
let tmpDir;
describe('bug #2268: parallel discuss — all undiscussed phases marked is_next_to_discuss', () => {
beforeEach(() => { tmpDir = createTempProject(); });
afterEach(() => { cleanup(tmpDir); });
test('two undiscussed phases: both marked is_next_to_discuss', () => {
writeState(tmpDir);
writeRoadmap(tmpDir, [
{ number: '1', name: 'Foundation' },
{ number: '2', name: 'Cloud Deployment' },
]);
const result = runGsdTools('init manager', tmpDir);
const output = JSON.parse(result.output);
assert.strictEqual(output.phases[0].is_next_to_discuss, true, 'phase 1 should be discussable');
assert.strictEqual(output.phases[1].is_next_to_discuss, true, 'phase 2 should also be discussable');
});
test('two undiscussed phases: both get discuss recommendations', () => {
writeState(tmpDir);
writeRoadmap(tmpDir, [
{ number: '1', name: 'Foundation' },
{ number: '2', name: 'Cloud Deployment' },
]);
const result = runGsdTools('init manager', tmpDir);
const output = JSON.parse(result.output);
const discussActions = output.recommended_actions.filter(a => a.action === 'discuss');
assert.strictEqual(discussActions.length, 2, 'should recommend discuss for both undiscussed phases');
const phases = discussActions.map(a => a.phase).sort();
assert.deepStrictEqual(phases, ['1', '2']);
});
test('five undiscussed phases: all five marked is_next_to_discuss', () => {
writeState(tmpDir);
writeRoadmap(tmpDir, [
{ number: '1', name: 'Alpha' },
{ number: '2', name: 'Beta' },
{ number: '3', name: 'Gamma' },
{ number: '4', name: 'Delta' },
{ number: '5', name: 'Epsilon' },
]);
const result = runGsdTools('init manager', tmpDir);
const output = JSON.parse(result.output);
for (const phase of output.phases) {
assert.strictEqual(phase.is_next_to_discuss, true, `phase ${phase.number} should be discussable`);
}
});
test('discussed phase stays false; undiscussed sibling is true', () => {
writeState(tmpDir);
writeRoadmap(tmpDir, [
{ number: '1', name: 'Foundation' },
{ number: '2', name: 'API Layer' },
]);
// scaffold CONTEXT.md to mark phase 1 as discussed
const dir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, '01-CONTEXT.md'), '# Context');
const result = runGsdTools('init manager', tmpDir);
const output = JSON.parse(result.output);
assert.strictEqual(output.phases[0].is_next_to_discuss, false, 'discussed phase must not be is_next_to_discuss');
assert.strictEqual(output.phases[1].is_next_to_discuss, true, 'undiscussed sibling must be is_next_to_discuss');
});
});
});
}

View File

@@ -83,3 +83,85 @@ describe('phases-command-router', () => {
assert.equal(message, 'Unknown phases subcommand. Available: list, clear');
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-1826-phases-clear-confirm.test.cjs — consolidation epic #1969 (B2 #1971)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-1826-phases-clear-confirm (consolidation epic #1969 B2 #1971)", () => {
/**
* Regression tests for bug #1826
*
* `phases clear` must require an explicit --confirm flag before deleting any
* phase directories. Without it, any accidental or hallucinated invocation
* wipes the entire .planning/phases/ tree with no warning.
*
* Rules:
* - Phase dirs present + no --confirm → non-zero exit, clear error message
* - Phase dirs present + --confirm → deletes, exits 0, reports count
* - No phase dirs + no --confirm → exits 0, cleared=0 (nothing to guard)
*/
'use strict';
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
describe('bug #1826: phases clear --confirm guard', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('phases clear without --confirm is rejected when phase dirs exist', () => {
const phasesDir = path.join(tmpDir, '.planning', 'phases');
fs.mkdirSync(path.join(phasesDir, '01-foundation'), { recursive: true });
fs.mkdirSync(path.join(phasesDir, '02-api'), { recursive: true });
const result = runGsdTools(['phases', 'clear'], tmpDir);
assert.ok(!result.success, 'should exit non-zero when dirs exist and --confirm absent');
assert.ok(
result.error.includes('--confirm'),
`error message must mention --confirm; got: ${result.error}`
);
// Dirs must be untouched
assert.ok(fs.existsSync(path.join(phasesDir, '01-foundation')), 'dirs must not be deleted');
assert.ok(fs.existsSync(path.join(phasesDir, '02-api')), 'dirs must not be deleted');
});
test('phases clear --confirm deletes dirs and reports count', () => {
const phasesDir = path.join(tmpDir, '.planning', 'phases');
fs.mkdirSync(path.join(phasesDir, '01-foundation'), { recursive: true });
fs.mkdirSync(path.join(phasesDir, '02-api'), { recursive: true });
const result = runGsdTools(['phases', 'clear', '--confirm'], tmpDir);
assert.ok(result.success, `should succeed with --confirm: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.cleared, 2);
assert.ok(!fs.existsSync(path.join(phasesDir, '01-foundation')), 'dirs should be removed');
});
test('phases clear without --confirm succeeds when no phase dirs exist', () => {
// .planning/phases/ exists but is empty — nothing to guard
const result = runGsdTools(['phases', 'clear'], tmpDir);
assert.ok(result.success, `should succeed with empty phases dir: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.cleared, 0);
});
});
});
}

View File

@@ -157,3 +157,153 @@ describe('profile-questionnaire command', () => {
assert.ok(out.questions[0].options, 'each question should have options');
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-866-profile-pipeline-temp-root.test.cjs — consolidation epic #1969 (B2 #1971)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-866-profile-pipeline-temp-root (consolidation epic #1969 B2 #1971)", () => {
'use strict';
/**
* Regression test for bug #866: profile-pipeline temp output dirs must be
* created under GSD_TEMP_DIR (path.join(os.tmpdir(), 'gsd')), not directly
* under os.tmpdir() root where reapStaleTempFiles() never scans.
*
* Hardening (adversarial-review follow-up):
* - TMPDIR/TEMP/TMP are redirected to a fixture-scoped directory so the child
* process's os.tmpdir() returns an isolated root. This prevents the test from
* touching the real shared temp root and keeps it out of the production
* reaper's view.
* - Both sides of the startsWith assertion are realpath-normalized to kill the
* macOS /var ↔ /private/var symlink flakiness.
* - An explicit exitCode === 0 assertion is added before JSON.parse so a
* non-zero early-exit produces a clear failure rather than a confusing parse
* error.
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempDir, cleanup } = require('./helpers.cjs');
describe('bug-866: profile-pipeline temp dirs under GSD_TEMP_DIR', () => {
let tmpDir;
let isolatedSysTmp;
beforeEach(() => {
tmpDir = createTempDir('gsd-866-');
// Create an isolated os.tmpdir() root inside the fixture so the child
// process never writes to the real shared temp dir.
isolatedSysTmp = path.join(tmpDir, 'systmp');
fs.mkdirSync(isolatedSysTmp, { recursive: true });
});
afterEach(() => {
cleanup(tmpDir);
});
// Helper: create a minimal synthetic sessions directory structure
function createSessions(root) {
const sessionsDir = path.join(root, 'projects');
const projectDir = path.join(sessionsDir, 'test-project-866');
fs.mkdirSync(projectDir, { recursive: true });
const messages = [
{ type: 'user', userType: 'external', message: { content: 'fix the login bug' }, timestamp: Date.now() },
{ type: 'assistant', message: { content: 'Sure.' }, timestamp: Date.now() },
];
fs.writeFileSync(
path.join(projectDir, 'session-001.jsonl'),
messages.map(m => JSON.stringify(m)).join('\n')
);
return sessionsDir;
}
test('extract-messages output_file is under GSD_TEMP_DIR, not os.tmpdir() root', () => {
const sessionsDir = createSessions(tmpDir);
// Pass the isolated tmp root so the child's os.tmpdir() = isolatedSysTmp.
// Belt-and-suspenders: set all three env vars Node checks (TMPDIR=POSIX,
// TEMP+TMP=Windows).
const result = runGsdTools(
['extract-messages', 'test-project-866', '--path', sessionsDir, '--raw'],
tmpDir,
{ TMPDIR: isolatedSysTmp, TEMP: isolatedSysTmp, TMP: isolatedSysTmp }
);
// Explicit exitCode check first — parse errors are confusing on non-zero exit.
assert.strictEqual(result.exitCode, 0, `extract-messages must exit 0; error: ${result.error}`);
assert.ok(result.success, `extract-messages failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.ok(out.output_file, 'should have output_file in result');
const outputFile = out.output_file;
// The expected GSD_TEMP_DIR from the child's perspective: isolatedSysTmp/gsd
const expectedGsdTempDir = path.join(isolatedSysTmp, 'gsd');
// Normalize both sides via realpath to kill macOS /var↔/private/var symlink
// flakiness. Realpath the existing output_file's parent directory (the file
// itself may have been cleaned up already, but the dir will exist).
const expectedRoot = fs.realpathSync(expectedGsdTempDir);
const outputDir = path.dirname(outputFile);
// The output dir must exist since the tool just wrote there; realpath it.
const actualDir = fs.realpathSync(outputDir);
assert.ok(
actualDir.startsWith(expectedRoot + path.sep) || actualDir === expectedRoot,
`output_file "${outputFile}" must be under GSD_TEMP_DIR "${expectedGsdTempDir}" (realpath: ${expectedRoot}); got dir "${actualDir}"`
);
// Must NOT be directly under the isolated systmp root (i.e., no gsd-pipeline-*
// at depth 1 of isolatedSysTmp).
const rel = path.relative(isolatedSysTmp, outputFile);
const depth1Dir = rel.split(path.sep)[0];
assert.ok(
!depth1Dir.startsWith('gsd-pipeline-'),
`output_file must not be in isolatedSysTmp/gsd-pipeline-* but got depth-1 dir: "${depth1Dir}"`
);
});
test('profile-sample output_file is under GSD_TEMP_DIR, not os.tmpdir() root', () => {
const sessionsDir = createSessions(tmpDir);
const result = runGsdTools(
['profile-sample', '--path', sessionsDir, '--raw'],
tmpDir,
{ TMPDIR: isolatedSysTmp, TEMP: isolatedSysTmp, TMP: isolatedSysTmp }
);
// Explicit exitCode check first.
assert.strictEqual(result.exitCode, 0, `profile-sample must exit 0; error: ${result.error}`);
assert.ok(result.success, `profile-sample failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.ok(out.output_file, 'should have output_file in result');
const outputFile = out.output_file;
const expectedGsdTempDir = path.join(isolatedSysTmp, 'gsd');
const expectedRoot = fs.realpathSync(expectedGsdTempDir);
const outputDir = path.dirname(outputFile);
const actualDir = fs.realpathSync(outputDir);
assert.ok(
actualDir.startsWith(expectedRoot + path.sep) || actualDir === expectedRoot,
`output_file "${outputFile}" must be under GSD_TEMP_DIR "${expectedGsdTempDir}" (realpath: ${expectedRoot}); got dir "${actualDir}"`
);
const rel = path.relative(isolatedSysTmp, outputFile);
const depth1Dir = rel.split(path.sep)[0];
assert.ok(
!depth1Dir.startsWith('gsd-profile-'),
`output_file must not be in isolatedSysTmp/gsd-profile-* but got depth-1 dir: "${depth1Dir}"`
);
});
});
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -376,3 +376,90 @@ describe('#1776: prune reads the current phase only from ## Current Position', (
);
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-1760-state-prune-noop-template-field.test.cjs — consolidation epic #1969 (B2 #1971)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-1760-state-prune-noop-template-field (consolidation epic #1969 B2 #1971)", () => {
'use strict';
// Regression test for issue #1760 — `state prune` no-ops on template-conformant
// STATE.md because it reads `Current Phase` only, never the `Phase: X of Y` line
// the canonical template emits.
//
// ADR-1769 Phase 7 fix: derive the current phase with a `Phase` / `Current Phase`
// fallback (mirroring buildStateFrontmatter), so prune engages on template STATE.md.
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
function writeStateMd(tmpDir, content) {
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content);
}
describe('#1760: state prune engages on template-conformant STATE.md (Phase: X of Y)', () => {
let tmpDir;
beforeEach(() => { tmpDir = createTempProject(); });
afterEach(() => { cleanup(tmpDir); });
test('prune engages when STATE.md uses "Phase: N of M" (no Current Phase field)', () => {
// Template-conformant STATE.md: Current Position uses `Phase: 10 of 15`, and
// there is NO `**Current Phase:**` body field. Pre-fix this made prune bail
// with "Only 0 phases — nothing to prune".
writeStateMd(tmpDir, [
'# Session State',
'',
'## Current Position',
'',
'Phase: 10 of 15',
'Plan: 2 of 4',
'Status: Executing Phase 10',
'',
'## Decisions',
'',
'- [Phase 1]: Old decision',
'- [Phase 3]: Older decision',
'- [Phase 9]: Recent decision',
'',
].join('\n'));
const result = runGsdTools('state prune --keep-recent 3 --dry-run', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
// Pre-fix: { pruned: false, reason: 'Only 0 phases — nothing to prune...' }.
// Post-fix: prune engages and reports a real cutoff_phase (10 - 3 = 7).
assert.strictEqual(out.pruned, false, 'dry-run must report pruned:false');
assert.ok(out.reason === undefined || !/Only 0 phases/i.test(String(out.reason)),
`prune must not bail with "Only 0 phases" on template STATE.md; got reason=${JSON.stringify(out.reason)}`);
assert.strictEqual(out.cutoff_phase, 7,
`cutoff_phase must be 7 (current 10 - keep-recent 3); got ${JSON.stringify(out.cutoff_phase)}`);
});
test('prune still engages when "Current Phase:" IS present (no regression)', () => {
writeStateMd(tmpDir, [
'# Session State',
'',
'**Current Phase:** 10',
'',
'## Decisions',
'',
'- [Phase 1]: Old decision',
'- [Phase 9]: Recent decision',
'',
].join('\n'));
const result = runGsdTools('state prune --keep-recent 3 --dry-run', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.cutoff_phase, 7);
});
});
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -24,3 +24,71 @@ describe('stats — MVP mode summary', () => {
);
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-3150-stats-json-decimal-phase-gaps.test.cjs — consolidation epic #1969 (B2 #1971)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-3150-stats-json-decimal-phase-gaps (consolidation epic #1969 B2 #1971)", () => {
'use strict';
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
describe('bug #3150: stats.json includes contiguous decimal phases when .10 exists', () => {
test('stats json preserves 06.7/06.8/06.9 alongside 06.10', () => {
const tmpDir = createTempProject('gsd-bug-3150-');
try {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n### Phase 06.6: P06.6\n**Goal:** G\n\n### Phase 06.7: P06.7\n**Goal:** G\n\n### Phase 06.8: P06.8\n**Goal:** G\n\n### Phase 06.9: P06.9\n**Goal:** G\n\n### Phase 06.10: P06.10\n**Goal:** G\n`
);
const dirs = ['06.6-a', '06.7-b', '06.8-c', '06.9-d', '06.10-e'];
for (const dirName of dirs) {
const phaseDir = path.join(tmpDir, '.planning', 'phases', dirName);
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, 'PLAN.md'), '# plan\n');
fs.writeFileSync(path.join(phaseDir, 'SUMMARY.md'), '# summary\n');
}
const result = runGsdTools('stats json', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
let output;
assert.doesNotThrow(
() => {
output = JSON.parse(result.output);
},
`Command output must be valid JSON. Raw output prefix: ${result.output.slice(0, 200)}`
);
assert.ok(
output && typeof output === 'object' && !Array.isArray(output),
`Expected object output, got: ${typeof output}`
);
assert.ok(
Array.isArray(output.phases),
`Expected output.phases array. Raw output prefix: ${result.output.slice(0, 200)}`
);
const phaseNumbers = output.phases.map((p) => p.number);
assert.deepStrictEqual(
phaseNumbers,
['06.6', '06.7', '06.8', '06.9', '06.10'],
'stats.json must not skip 06.7/06.8/06.9 when 06.10 exists'
);
assert.equal(output.phases_total, 5);
assert.equal(output.total_plans, 5);
assert.equal(output.total_summaries, 5);
} finally {
cleanup(tmpDir);
}
});
});
});
}

View File

@@ -322,3 +322,212 @@ describe('detect-custom-files — update workflow backup detection (#1997)', ()
);
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-2942-detect-custom-skills.test.cjs — consolidation epic #1969 (B2 #1971)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-2942-detect-custom-skills (consolidation epic #1969 B2 #1971)", () => {
/**
* GSD Tools Tests — detect-custom-files misses skills/ directory (#2942)
*
* After v1.39.0 skill consolidation (#2790), skills/ became a GSD-managed root.
* GSD_MANAGED_DIRS was missing 'skills', so user-added GSD-prefixed skill
* directories like skills/gsd-custom-skill/SKILL.md were never walked and got
* silently destroyed during /gsd-update.
*/
'use strict';
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { runGsdTools, createTempDir, cleanup } = require('./helpers.cjs');
function sha256(content) {
return crypto.createHash('sha256').update(content).digest('hex');
}
/**
* Write a fake gsd-file-manifest.json into configDir with the given file entries.
* Each entry is also written to disk so the directory structure exists.
*/
function writeManifest(configDir, files) {
const manifest = {
version: '1.39.0',
timestamp: new Date().toISOString(),
files: {}
};
for (const [relPath, content] of Object.entries(files)) {
const fullPath = path.join(configDir, relPath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content);
manifest.files[relPath] = sha256(content);
}
fs.writeFileSync(
path.join(configDir, 'gsd-file-manifest.json'),
JSON.stringify(manifest, null, 2)
);
}
/**
* Write a file inside configDir (creating parent dirs), but do NOT add it to the manifest.
*/
function writeCustomFile(configDir, relPath, content) {
const fullPath = path.join(configDir, relPath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content);
}
describe('detect-custom-files — skills/ directory missing from GSD_MANAGED_DIRS (#2942)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempDir('gsd-2942-skills-');
});
afterEach(() => {
cleanup(tmpDir);
});
// Test 1: detects custom GSD-prefixed skill in skills/gsd-<name>/SKILL.md
test('detects custom skill file at skills/gsd-<name>/SKILL.md', () => {
writeManifest(tmpDir, {
'skills/gsd-planner/SKILL.md': '# GSD Planner Skill\n',
});
// User-added custom GSD-prefixed skill — NOT in manifest
writeCustomFile(tmpDir, 'skills/gsd-test-custom/SKILL.md', '# My Custom Skill\n');
const result = runGsdTools(
['detect-custom-files', '--config-dir', tmpDir],
tmpDir
);
assert.ok(result.success, `Command failed: ${result.error}`);
const json = JSON.parse(result.output);
assert.ok(Array.isArray(json.custom_files), 'custom_files should be an array');
assert.ok(json.custom_count >= 1, `custom_count should be >= 1, got ${json.custom_count}`);
assert.ok(
json.custom_files.includes('skills/gsd-test-custom/SKILL.md'),
`skills/gsd-test-custom/SKILL.md should be in custom_files; got: ${JSON.stringify(json.custom_files)}`
);
});
test('does not detect non-gsd shared skills preserved by installer (#1325)', () => {
writeManifest(tmpDir, {
'skills/gsd-planner/SKILL.md': '# GSD Planner Skill\n',
});
writeCustomFile(tmpDir, 'skills/test-custom/SKILL.md', '# My Custom Skill\n');
const result = runGsdTools(
['detect-custom-files', '--config-dir', tmpDir],
tmpDir
);
assert.ok(result.success, `Command failed: ${result.error}`);
const json = JSON.parse(result.output);
assert.ok(Array.isArray(json.custom_files), 'custom_files should be an array');
assert.ok(
!json.custom_files.includes('skills/test-custom/SKILL.md'),
`non-gsd shared skill should not be in custom_files; got: ${JSON.stringify(json.custom_files)}`
);
});
// Test 2: does not flag GSD-owned skills as custom (manifest-tracked path NOT in custom_files)
test('does not flag GSD-owned skill as custom when it is tracked in manifest', () => {
writeManifest(tmpDir, {
'skills/gsd-planner/SKILL.md': '# GSD Planner Skill\n',
});
// No extra files — only the manifest-tracked skill exists
const result = runGsdTools(
['detect-custom-files', '--config-dir', tmpDir],
tmpDir
);
assert.ok(result.success, `Command failed: ${result.error}`);
const json = JSON.parse(result.output);
assert.ok(Array.isArray(json.custom_files), 'custom_files should be an array');
assert.ok(
!json.custom_files.includes('skills/gsd-planner/SKILL.md'),
`GSD-owned skill should NOT be in custom_files; got: ${JSON.stringify(json.custom_files)}`
);
});
// Test 3: regression guard — still detects custom files in gsd-core/workflows/
test('regression: still detects custom files in gsd-core/workflows/', () => {
writeManifest(tmpDir, {
'gsd-core/workflows/plan-phase.md': '# Plan Phase\n',
'skills/gsd-planner/SKILL.md': '# GSD Planner Skill\n',
});
writeCustomFile(tmpDir, 'gsd-core/workflows/custom-workflow.md', '# My Custom Workflow\n');
const result = runGsdTools(
['detect-custom-files', '--config-dir', tmpDir],
tmpDir
);
assert.ok(result.success, `Command failed: ${result.error}`);
const json = JSON.parse(result.output);
assert.ok(
json.custom_files.includes('gsd-core/workflows/custom-workflow.md'),
`custom workflow should still be detected; got: ${JSON.stringify(json.custom_files)}`
);
});
// Test 4: custom_count matches custom_files.length
test('custom_count matches custom_files.length when multiple custom gsd-prefixed skills exist', () => {
writeManifest(tmpDir, {
'skills/gsd-planner/SKILL.md': '# GSD Planner Skill\n',
});
writeCustomFile(tmpDir, 'skills/gsd-test-custom/SKILL.md', '# Custom Skill One\n');
writeCustomFile(tmpDir, 'skills/gsd-another-custom/SKILL.md', '# Custom Skill Two\n');
const result = runGsdTools(
['detect-custom-files', '--config-dir', tmpDir],
tmpDir
);
assert.ok(result.success, `Command failed: ${result.error}`);
const json = JSON.parse(result.output);
assert.strictEqual(
json.custom_count,
json.custom_files.length,
`custom_count (${json.custom_count}) should equal custom_files.length (${json.custom_files.length})`
);
assert.strictEqual(json.custom_count, 2, 'should detect exactly 2 custom skill files');
});
// Test 5: manifest_found: true when manifest is present
test('manifest_found is true when manifest is present', () => {
writeManifest(tmpDir, {
'skills/gsd-planner/SKILL.md': '# GSD Planner Skill\n',
});
const result = runGsdTools(
['detect-custom-files', '--config-dir', tmpDir],
tmpDir
);
assert.ok(result.success, `Command failed: ${result.error}`);
const json = JSON.parse(result.output);
assert.strictEqual(json.manifest_found, true, 'manifest_found should be true');
});
});
});
}

View File

@@ -1011,3 +1011,328 @@ describe('verify key-links command', () => {
);
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-967-verify-key-links-strict-paths.test.cjs — consolidation epic #1969 (B2 #1971)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-967-verify-key-links-strict-paths (consolidation epic #1969 B2 #1971)", () => {
/**
* Regression test for bug #967: verify key-links reads from:/to: as literal
* relative file paths; the reference docs wrongly implied component/endpoint
* values were valid. Fix direction: author-strict — docs corrected to match code.
*
* Contract pinned here:
* 1. from: must be a relative file path; pattern: is evaluated against its content.
* 2. from: pointing to a non-existent file → verified:false, detail "Source file not found".
* 3. docs/reference/plan-md.md reference example uses a file path for to: (NOT /api/feed).
*/
'use strict';
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
// ─── helpers ──────────────────────────────────────────────────────────────────
function writePlanWithKeyLinks(tmpDir, keyLinksYaml, opts) {
// parseMustHavesBlock expects 4-space indent for block name, 6-space for items
const wave = (opts && opts.wave != null) ? opts.wave : 1;
const filesModified = (opts && opts.filesModified) ? opts.filesModified : ['src/a.js'];
const filesModifiedYaml = filesModified.length === 1
? `[${filesModified[0]}]`
: `[${filesModified.join(', ')}]`;
const content = [
'---',
'phase: 01-test',
'plan: 01',
'type: execute',
`wave: ${wave}`,
'depends_on: []',
`files_modified: ${filesModifiedYaml}`,
'autonomous: true',
'must_haves:',
' key_links:',
...keyLinksYaml.map(line => ` ${line}`),
'---',
'',
'<tasks>',
'<task type="auto">',
' <name>Task 1: Do thing</name>',
' <files>src/a.js</files>',
' <action>Do it</action>',
' <verify><automated>echo ok</automated></verify>',
' <done>Done</done>',
'</task>',
'</tasks>',
].join('\n');
const planPath = path.join(tmpDir, '.planning', 'phases', '01-test', '01-01-PLAN.md');
fs.mkdirSync(path.dirname(planPath), { recursive: true });
fs.writeFileSync(planPath, content);
}
/**
* Write an additional plan file in the same phase directory with specific
* wave + files_modified (no key_links, just declaring future artifacts).
*/
function writeCompanionPlan(tmpDir, planFileName, wave, filesModified) {
const filesModifiedYaml = `[${filesModified.join(', ')}]`;
const content = [
'---',
'phase: 01-test',
'plan: 02',
'type: execute',
`wave: ${wave}`,
'depends_on: []',
`files_modified: ${filesModifiedYaml}`,
'autonomous: true',
'must_haves:',
' key_links: []',
'---',
'',
'<tasks>',
'<task type="auto">',
' <name>Task 2: Create file</name>',
' <files>src/b.js</files>',
' <action>Create it</action>',
' <verify><automated>echo ok</automated></verify>',
' <done>Done</done>',
'</task>',
'</tasks>',
].join('\n');
const planPath = path.join(tmpDir, '.planning', 'phases', '01-test', planFileName);
fs.writeFileSync(planPath, content);
}
describe('bug-967 verify key-links strict file-path contract', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true });
});
afterEach(() => {
cleanup(tmpDir);
});
// ── 1. Happy path: from: is a real file path and pattern: matches ──────────
test('verified:true when from: is a relative file path and pattern: matches', () => {
writePlanWithKeyLinks(tmpDir, [
'- from: "src/component.js"',
' to: "src/api/feed.js"',
' via: "fetch in useEffect"',
' pattern: "fetch.*api/feed"',
]);
// Create the source file containing the pattern
fs.writeFileSync(
path.join(tmpDir, 'src', 'component.js'),
"fetch('/api/feed').then(r => r.json());\n",
);
// Create the target file too (not strictly needed for this path, but realistic)
fs.mkdirSync(path.join(tmpDir, 'src', 'api'), { recursive: true });
fs.writeFileSync(path.join(tmpDir, 'src', 'api', 'feed.js'), 'module.exports = {};\n');
const result = runGsdTools(
'verify key-links .planning/phases/01-test/01-01-PLAN.md',
tmpDir,
);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(
output.all_verified,
true,
`Expected all_verified:true (file-path from: + matching pattern:). Got: ${JSON.stringify(output)}`,
);
assert.strictEqual(output.links[0].verified, true);
});
// ── 2. Contract: missing source file → verified:false, explicit detail ─────
test('verified:false with "Source file not found" detail when from: file does not exist', () => {
writePlanWithKeyLinks(tmpDir, [
'- from: "src/missing-file.js"',
' to: "src/api/feed.js"',
' via: "fetch in useEffect"',
' pattern: "fetch.*api/feed"',
]);
// Deliberately do NOT create src/missing-file.js
const result = runGsdTools(
'verify key-links .planning/phases/01-test/01-01-PLAN.md',
tmpDir,
);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(
output.links[0].verified,
false,
`Expected verified:false for absent source file. Got: ${JSON.stringify(output.links[0])}`,
);
assert.ok(
output.links[0].detail.includes('Source file not found'),
`Expected detail to include "Source file not found". Got: "${output.links[0].detail}"`,
);
});
// ── 3. Regression #1202: missing from: file promised by a same-wave plan → pending:true ──
//
// A from: file absent on disk but listed in files_modified of another plan at
// the same wave must be reported pending:true (not verified:false) and must NOT
// count against the all_verified gate.
//
// This test MUST FAIL before the fix is applied (the gate hard-fails today).
test('pending:true and all_verified:true when from: file is promised by a same-wave plan', () => {
// Plan under test is wave 2; it references src/future-artifact.js which does not
// exist on disk yet.
writePlanWithKeyLinks(tmpDir, [
'- from: "src/future-artifact.js"',
' to: "src/consumer.js"',
' via: "requires future-artifact"',
' pattern: "future-artifact"',
], { wave: 2, filesModified: ['src/consumer.js'] });
// A companion plan also at wave 2 declares src/future-artifact.js in files_modified
writeCompanionPlan(tmpDir, '01-02-PLAN.md', 2, ['src/future-artifact.js']);
// Do NOT create src/future-artifact.js on disk — it is a planned future file
const result = runGsdTools(
'verify key-links .planning/phases/01-test/01-01-PLAN.md',
tmpDir,
);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(
out.links[0].pending,
true,
`Expected pending:true for a from: file promised by a same-wave plan. Got: ${JSON.stringify(out.links[0])}`,
);
assert.strictEqual(
out.all_verified,
true,
`Expected all_verified:true (pending links should not fail the gate). Got: ${JSON.stringify(out)}`,
);
assert.strictEqual(
out.links[0].verified,
false,
`Expected verified:false (file is not yet verified — just pending). Got: ${JSON.stringify(out.links[0])}`,
);
});
// ── 4. Regression #1202: missing from: file promised by a LATER-wave plan → pending:true ──
test('pending:true and all_verified:true when from: file is promised by a later-wave plan', () => {
// Plan under test is wave 1; companion plan is wave 3 (later wave promises the file)
writePlanWithKeyLinks(tmpDir, [
'- from: "src/later-artifact.js"',
' to: "src/consumer.js"',
' via: "later wave dependency"',
], { wave: 1, filesModified: ['src/consumer.js'] });
writeCompanionPlan(tmpDir, '01-02-PLAN.md', 3, ['src/later-artifact.js']);
const result = runGsdTools(
'verify key-links .planning/phases/01-test/01-01-PLAN.md',
tmpDir,
);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(
out.links[0].pending,
true,
`Expected pending:true for from: file promised by a later-wave plan. Got: ${JSON.stringify(out.links[0])}`,
);
assert.strictEqual(
out.all_verified,
true,
`Expected all_verified:true (pending links not counted against gate). Got: ${JSON.stringify(out)}`,
);
});
// ── 5. Regression #1202: missing from: file NOT promised by any plan → hard failure ──
//
// Absence of from: file with no plan promising it must remain a genuine verified:false failure.
test('verified:false and all_verified:false when from: file is absent and not promised by any plan', () => {
writePlanWithKeyLinks(tmpDir, [
'- from: "src/truly-missing.js"',
' to: "src/consumer.js"',
' via: "no plan promises this"',
], { wave: 1, filesModified: ['src/consumer.js'] });
// No companion plan that promises src/truly-missing.js
const result = runGsdTools(
'verify key-links .planning/phases/01-test/01-01-PLAN.md',
tmpDir,
);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(
out.links[0].verified,
false,
`Expected verified:false for absent+unpromised from: file. Got: ${JSON.stringify(out.links[0])}`,
);
assert.strictEqual(
out.all_verified,
false,
`Expected all_verified:false (hard failure). Got: ${JSON.stringify(out)}`,
);
// pending must not be true
assert.notStrictEqual(
out.links[0].pending,
true,
`Expected pending not to be true for an absent+unpromised file. Got: ${JSON.stringify(out.links[0])}`,
);
});
// ── 6. Doc-contract guard: reference example must use a file path for to: ──
//
// The old reference example had to: "/api/feed" (an HTTP endpoint).
// After fix #967, to: must be a relative file path like "app/api/feed/route.ts".
// This test reads the canonical docs file and asserts the example is consistent
// with the strict-path contract.
//
// allow-test-rule: <runtime-contract-is-the-product> the plan-md.md reference (see #967)
// example IS the documented authoring surface for key_links; asserting it uses
// a file path (not an endpoint) directly tests the documented contract.
test('docs/reference/plan-md.md key_links example uses a relative file path for to:, not an HTTP endpoint', () => {
// Locate plan-md.md relative to this test file's repo root
const docPath = path.join(__dirname, '..', 'docs', 'reference', 'plan-md.md');
assert.ok(fs.existsSync(docPath), `plan-md.md not found at ${docPath}`);
const content = fs.readFileSync(docPath, 'utf-8'); // allow-test-rule: <runtime-contract-is-the-product> the plan-md.md reference example IS the documented authoring surface for key_links; asserting it uses a file path (not an endpoint) directly tests the documented contract. (see #967)
// Find the key_links block in the annotated example (the first YAML frontmatter fence)
// The bad old value was: to: "/api/feed"
assert.ok(
!content.includes('to: "/api/feed"'),
'docs/reference/plan-md.md still contains the endpoint-style to: "/api/feed" — ' +
'the reference example must use a relative file path (e.g. "app/api/feed/route.ts") ' +
'to match the strict file-path contract.',
);
// Also assert the corrected example actually uses a path-like value
// (must contain at least one '/' and not start with 'http')
const toMatch = content.match(/key_links:[\s\S]*?to:\s*"([^"]+)"/);
assert.ok(
toMatch,
'Could not find a to: field in the key_links example in plan-md.md',
);
const toValue = toMatch[1];
assert.ok(
!toValue.startsWith('/api') && !toValue.startsWith('http'),
`to: value in the docs example looks like an HTTP endpoint: "${toValue}". ` +
'It must be a relative file path.',
);
});
});
});
}