refactor(tests): consolidate Phase Lifecycle Module — 20 files → 4 (#3741)

* chore(tests): lint rule — cap test files per production module at 2

Adds scripts/lint-test-file-count.cjs with a ratcheted allowlist
(scripts/lint-test-file-count.allowlist.json) capturing today's
30 violating clusters as a ceiling. New entries blocked at PR time;
reductions ratchet automatically.

Wires into .github/workflows/test.yml as a new step in lint-tests.

Refs #3737

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(tests): consolidate Phase Lifecycle Module — 20 files → 4

- Merge 10 CJS bug-fix test files into tests/phase.test.cjs
- Merge sdk/src/phase-runner-types.test.ts + sdk/src/phase-prompt.test.ts
  into sdk/src/phase-runner.test.ts (97 → 152 tests, 0 failures)
- Rename 4 mis-attributed test files out of phase cluster:
    phase-researcher-app-aware    → gsd-researcher-app-aware
    phase-researcher-flow-diagram → gsd-researcher-flow-diagram
    feat-3023-phase-type-models   → feat-3023-model-phase-types
    phase-6-cjs-sdk-seam-contracts → cjs-sdk-bridge-seam-contracts
- Fix phasePlanIndex (#3430): non-canonical plan filename warning now
  surfaces in data.warnings[] as "Ignored noncanonical plan files: ..."
  instead of a separate singular data.warning field
- Update allowlist: phase ceiling 20 → 4 (closes #3740)
- Add Phase Lifecycle Module glossary entry to CONTEXT.md

Closes #3740

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(phase-roadmap-mutation): replaceInCurrentMilestone handles active milestone inside <details> block

When the active milestone is wrapped in a <details> block (e.g. user
collapsed it, or milestone transition), the after-</details> slice is
empty or contains only footer text — the pattern never matches and the
replacement is silently dropped.

Fix: when after.replace() produces no change, fall back to replacing
inside the last <details>...</details> block. Shipped-milestone blocks
are untouched because only the last <details> block is targeted.

Closes #2641

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(changeset): add required type/pr frontmatter to 3740 fragment

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-20 20:57:06 -04:00
committed by GitHub
parent 4f56c3b10b
commit aab01f43e9
24 changed files with 2602 additions and 2919 deletions

View File

@@ -1,6 +0,0 @@
---
type: Added
pr: 3733
---
<!-- docs-exempt: internal CI lint rule only — adds scripts/, tests/, and a workflow step; no user-facing commands, output, or configuration changed -->
**New CI lint rule `lint-test-file-count` prevents per-feature test-file proliferation** — a recurring pattern where one production module (e.g. `phase.cjs`, `init.cjs`) accumulates 5–20 separate test files over time as PRs add issue-stamped files (`bug-NNNN-*.test.cjs`, `feat-NNNN-*.test.cjs`) next to an existing primary. The rule scans `sdk/src/query/`, `sdk/src/`, `get-shit-done/bin/lib/`, and `bin/` for production modules, then counts matching test files in `tests/` and `sdk/src/**/`. Each module is capped at 2 (primary + one integration). Existing over-limit clusters are frozen in `scripts/lint-test-file-count.allowlist.json` at their current count (30 modules; `phase` is the worst at 20 files). The allowlist ratchets downward automatically — reducing a cluster is always allowed; increasing it requires a PR-description justification. Added `"lint:test-file-count"` npm script and a `Lint — test file count per module` step in the `lint-tests` CI job.

View File

@@ -0,0 +1,22 @@
---
type: Added
pr: 3740
---
<!-- docs-exempt: internal test refactor only — no user-facing surface changed -->
## Summary
Consolidates the Phase Lifecycle Module test cluster from 20 files to 4, satisfying the lint-test-file-count allowlist ceiling.
- Merged 10 small CJS bug-fix test files into `tests/phase.test.cjs`
- Merged `sdk/src/phase-runner-types.test.ts` and `sdk/src/phase-prompt.test.ts` into `sdk/src/phase-runner.test.ts`
- Renamed 4 mis-attributed test files whose production seam is not `phase.cjs`/`phase.ts`:
- `phase-researcher-app-aware` → `gsd-researcher-app-aware`
- `phase-researcher-flow-diagram` → `gsd-researcher-flow-diagram`
- `feat-3023-phase-type-models` → `feat-3023-model-phase-types`
- `phase-6-cjs-sdk-seam-contracts` → `cjs-sdk-bridge-seam-contracts`
- Fixed `phasePlanIndex` (#3430): non-canonical plan filename warning now flows through `warnings[]` array alongside other diagnostics instead of a separate singular `warning` field
- Updated `scripts/lint-test-file-count.allowlist.json`: `phase` ceiling 20 → 4
- Added Phase Lifecycle Module glossary entry to `CONTEXT.md`
Closes #3740

View File

@@ -14,6 +14,9 @@ Module owning `milestone complete` (archive roadmap/requirements/phases, build M
### Dispatch Pipeline Module
Module that composes Dispatch Policy Module, Query Execution Policy Module, and per-stage handlers (input-validation, plan, execution, result-builder, formatting, error-mapping, observability) into the end-to-end pipeline that produces a `QueryDispatchResult`. Entry point: `sdk/src/query/query-dispatch.ts`. Typed contract: `sdk/src/query/query-dispatch-contract.ts`.
### Phase Lifecycle Module
Module owning phase create, rename, complete, remove, list, and plan-index operations, plus phase-dir prefix validation, STATE.md staleness detection, and auto-prune behaviour. Entry points: `get-shit-done/bin/lib/phase.cjs` (CJS surface) and `sdk/src/query/phase.ts` (SDK native query). Typed events: `GSDPhaseStartEvent`, `GSDPhaseStepStartEvent`, `GSDPhaseStepCompleteEvent`, `GSDPhaseCompleteEvent` (see `sdk/src/types.ts`). Phase execution is driven by `sdk/src/phase-runner.ts`; prompt construction by `sdk/src/phase-prompt.ts`.
### Dispatch Policy Module
Module owning dispatch error mapping, fallback policy, timeout classification, and CLI exit mapping contract.

View File

@@ -1,7 +1,7 @@
{
"_doc": "Baseline of modules currently exceeding the 2-test-file limit. Each entry locks in TODAY's count as the ceiling. Reductions are ratcheted automatically — when a cluster drops to ≤ 2, remove its entry. New entries require justification in PR description.",
"modules": {
"phase": { "current": 20, "issue": "TBD" },
"phase": { "current": 4, "issue": "3740" },
"worktree": { "current": 13, "issue": "TBD" },
"milestone": { "current": 10, "issue": "TBD" },
"roadmap": { "current": 9, "issue": "TBD" },

View File

@@ -1,535 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { PromptFactory, extractBlock, extractSteps, PHASE_WORKFLOW_MAP } from './phase-prompt.js';
import { PhaseType } from './types.js';
import type { ContextFiles, ParsedPlan, PlanFrontmatter } from './types.js';
// ─── Helpers ─────────────────────────────────────────────────────────────────
async function createTempDir(): Promise<string> {
return mkdtemp(join(tmpdir(), 'gsd-prompt-'));
}
function makeWorkflowContent(purpose: string, steps: string[]): string {
const stepBlocks = steps
.map((s, i) => `<step name="step_${i + 1}">\n${s}\n</step>`)
.join('\n\n');
return `<purpose>\n${purpose}\n</purpose>\n\n<process>\n${stepBlocks}\n</process>`;
}
function makeAgentDef(name: string, tools: string, role: string): string {
return `---\nname: ${name}\ntools: ${tools}\n---\n\n<role>\n${role}\n</role>`;
}
function makeParsedPlan(overrides?: Partial<ParsedPlan>): ParsedPlan {
return {
frontmatter: {
phase: 'execute',
plan: 'test-plan',
type: 'feature',
wave: 1,
depends_on: [],
files_modified: [],
autonomous: true,
requirements: [],
must_haves: { truths: [], artifacts: [], key_links: [] },
} as PlanFrontmatter,
objective: 'Test objective',
execution_context: [],
context_refs: [],
tasks: [],
raw: '',
...overrides,
};
}
// ─── extractBlock tests ──────────────────────────────────────────────────────
describe('extractBlock', () => {
it('extracts content from a simple block', () => {
const content = '<purpose>\nDo the thing.\n</purpose>';
expect(extractBlock(content, 'purpose')).toBe('Do the thing.');
});
it('extracts content from block with attributes', () => {
const content = '<step name="init" priority="first">\nLoad context.\n</step>';
expect(extractBlock(content, 'step')).toBe('Load context.');
});
it('returns empty string for missing block', () => {
const content = '<purpose>Something</purpose>';
expect(extractBlock(content, 'role')).toBe('');
});
it('extracts multiline content', () => {
const content = '<role>\nLine 1\nLine 2\nLine 3\n</role>';
expect(extractBlock(content, 'role')).toBe('Line 1\nLine 2\nLine 3');
});
});
describe('extractSteps', () => {
it('extracts multiple steps from process content', () => {
const process = `
<step name="init">Initialize</step>
<step name="execute">Run tasks</step>
<step name="verify">Check results</step>`;
const steps = extractSteps(process);
expect(steps).toHaveLength(3);
expect(steps[0]).toEqual({ name: 'init', content: 'Initialize' });
expect(steps[1]).toEqual({ name: 'execute', content: 'Run tasks' });
expect(steps[2]).toEqual({ name: 'verify', content: 'Check results' });
});
it('returns empty array for no steps', () => {
expect(extractSteps('no steps here')).toEqual([]);
});
it('handles steps with priority attributes', () => {
const process = '<step name="init" priority="first">\nDo first.\n</step>';
const steps = extractSteps(process);
expect(steps).toHaveLength(1);
expect(steps[0].name).toBe('init');
expect(steps[0].content).toBe('Do first.');
});
});
// ─── PromptFactory tests ─────────────────────────────────────────────────────
describe('PromptFactory', () => {
let tempDir: string;
let workflowsDir: string;
let agentsDir: string;
beforeEach(async () => {
tempDir = await createTempDir();
workflowsDir = join(tempDir, 'workflows');
agentsDir = join(tempDir, 'agents');
await mkdir(workflowsDir, { recursive: true });
await mkdir(agentsDir, { recursive: true });
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
function makeFactory(): PromptFactory {
// sdkPromptsDir points to a non-existent temp subdir so real sdk/prompts/ files
// don't interfere — tests control exactly which files exist on disk.
return new PromptFactory({
gsdInstallDir: tempDir,
agentsDir,
sdkPromptsDir: join(tempDir, 'sdk-prompts-does-not-exist'),
});
}
describe('buildPrompt', () => {
it('assembles research prompt with role + purpose + process + context', async () => {
await writeFile(
join(workflowsDir, 'research-phase.md'),
makeWorkflowContent('Research the phase.', ['Gather info', 'Analyze findings']),
);
await writeFile(
join(agentsDir, 'gsd-phase-researcher.md'),
makeAgentDef('gsd-phase-researcher', 'Read, Grep, Bash', 'You are a researcher.'),
);
const factory = makeFactory();
const contextFiles: ContextFiles = {
state: '# State\nproject: test',
roadmap: '# Roadmap\nphases listed',
};
const prompt = await factory.buildPrompt(PhaseType.Research, null, contextFiles);
expect(prompt).toContain('## Agent Instructions');
expect(prompt).toContain('You are a researcher.');
expect(prompt).toContain('## Purpose');
expect(prompt).toContain('Research the phase.');
expect(prompt).toContain('## Process');
expect(prompt).toContain('Gather info');
expect(prompt).toContain('## Context');
expect(prompt).toContain('# State');
expect(prompt).toContain('# Roadmap');
// Cache-friendly ordering (#1614): stable prefix before variable context
const agentIdx = prompt.indexOf('## Agent Instructions');
const contextIdx = prompt.indexOf('## Context');
expect(agentIdx).toBeLessThan(contextIdx);
});
it('assembles plan prompt with all context files', async () => {
await writeFile(
join(workflowsDir, 'plan-phase.md'),
makeWorkflowContent('Plan the implementation.', ['Break down tasks']),
);
await writeFile(
join(agentsDir, 'gsd-planner.md'),
makeAgentDef('gsd-planner', 'Read, Write, Bash', 'You are a planner.'),
);
const factory = makeFactory();
const contextFiles: ContextFiles = {
state: '# State',
roadmap: '# Roadmap',
context: '# Context',
research: '# Research',
requirements: '# Requirements',
};
const prompt = await factory.buildPrompt(PhaseType.Plan, null, contextFiles);
expect(prompt).toContain('You are a planner.');
expect(prompt).toContain('Plan the implementation.');
expect(prompt).toContain('# State');
expect(prompt).toContain('# Research');
expect(prompt).toContain('# Requirements');
expect(prompt).toContain('You are a planner.');
});
it('delegates execute phase with plan to buildExecutorPrompt', async () => {
await writeFile(
join(agentsDir, 'gsd-executor.md'),
makeAgentDef('gsd-executor', 'Read, Write, Edit, Bash', 'You are an executor.'),
);
const factory = makeFactory();
const plan = makeParsedPlan({ objective: 'Build the auth system' });
const contextFiles: ContextFiles = { state: '# State' };
const prompt = await factory.buildPrompt(PhaseType.Execute, plan, contextFiles);
// buildExecutorPrompt produces structured output with ## Objective
expect(prompt).toContain('## Objective');
expect(prompt).toContain('Build the auth system');
expect(prompt).toContain('## Role');
expect(prompt).toContain('You are an executor.');
});
it('handles execute phase without plan (non-delegation path)', async () => {
await writeFile(
join(workflowsDir, 'execute-plan.md'),
makeWorkflowContent('Execute the plan.', ['Run tasks']),
);
await writeFile(
join(agentsDir, 'gsd-executor.md'),
makeAgentDef('gsd-executor', 'Read, Write, Edit, Bash', 'You are an executor.'),
);
const factory = makeFactory();
const contextFiles: ContextFiles = { state: '# State' };
const prompt = await factory.buildPrompt(PhaseType.Execute, null, contextFiles);
// Falls through to general assembly path
expect(prompt).toContain('## Agent Instructions');
expect(prompt).toContain('You are an executor.');
expect(prompt).toContain('## Purpose');
expect(prompt).toContain('Execute the plan.');
});
it('assembles verify prompt with phase instructions', async () => {
await writeFile(
join(workflowsDir, 'verify-phase.md'),
makeWorkflowContent('Verify phase goals.', ['Check artifacts', 'Run tests']),
);
await writeFile(
join(agentsDir, 'gsd-verifier.md'),
makeAgentDef('gsd-verifier', 'Read, Bash, Grep', 'You are a verifier.'),
);
const factory = makeFactory();
const contextFiles: ContextFiles = {
state: '# State',
roadmap: '# Roadmap',
requirements: '# Requirements',
};
const prompt = await factory.buildPrompt(PhaseType.Verify, null, contextFiles);
expect(prompt).toContain('You are a verifier.');
expect(prompt).toContain('Verify phase goals.');
expect(prompt).toContain('You are a verifier.');
});
it('assembles discuss prompt without agent role (no dedicated agent)', async () => {
await writeFile(
join(workflowsDir, 'discuss-phase.md'),
makeWorkflowContent('Discuss implementation decisions.', ['Identify areas']),
);
const factory = makeFactory();
const contextFiles: ContextFiles = { state: '# State' };
const prompt = await factory.buildPrompt(PhaseType.Discuss, null, contextFiles);
// Discuss has no agent, so no Agent Instructions section
expect(prompt).not.toContain('## Agent Instructions');
expect(prompt).toContain('## Purpose');
expect(prompt).toContain('Discuss implementation decisions.');
});
it('handles missing workflow file gracefully', async () => {
// No workflow files on disk
await writeFile(
join(agentsDir, 'gsd-phase-researcher.md'),
makeAgentDef('gsd-phase-researcher', 'Read, Bash', 'You are a researcher.'),
);
const factory = makeFactory();
const contextFiles: ContextFiles = { state: '# State' };
const prompt = await factory.buildPrompt(PhaseType.Research, null, contextFiles);
// Should still produce a prompt with agent instructions and context
expect(prompt).toContain('## Agent Instructions');
expect(prompt).toContain('## Context');
expect(prompt).not.toContain('## Purpose');
});
it('handles missing agent def gracefully', async () => {
await writeFile(
join(workflowsDir, 'research-phase.md'),
makeWorkflowContent('Research the phase.', ['Gather info']),
);
// No agent file on disk
const factory = makeFactory();
const contextFiles: ContextFiles = { state: '# State' };
const prompt = await factory.buildPrompt(PhaseType.Research, null, contextFiles);
expect(prompt).not.toContain('## Agent Instructions');
expect(prompt).toContain('## Purpose');
expect(prompt).toContain('Research the phase.');
});
it('omits empty context section when no files provided', async () => {
await writeFile(
join(workflowsDir, 'discuss-phase.md'),
makeWorkflowContent('Discuss things.', ['Talk']),
);
const factory = makeFactory();
const contextFiles: ContextFiles = {};
const prompt = await factory.buildPrompt(PhaseType.Discuss, null, contextFiles);
expect(prompt).not.toContain('## Context');
});
});
describe('loadWorkflowFile', () => {
it('loads existing workflow file', async () => {
await writeFile(
join(workflowsDir, 'research-phase.md'),
'workflow content',
);
const factory = makeFactory();
const content = await factory.loadWorkflowFile(PhaseType.Research);
expect(content).toBe('workflow content');
});
it('returns undefined for missing workflow file', async () => {
const factory = makeFactory();
const content = await factory.loadWorkflowFile(PhaseType.Research);
expect(content).toBeUndefined();
});
});
describe('loadAgentDef', () => {
it('loads agent def from agents dir', async () => {
await writeFile(
join(agentsDir, 'gsd-executor.md'),
'agent content',
);
const factory = makeFactory();
const content = await factory.loadAgentDef(PhaseType.Execute);
expect(content).toBe('agent content');
});
it('returns undefined for phases with no agent (discuss)', async () => {
const factory = makeFactory();
const content = await factory.loadAgentDef(PhaseType.Discuss);
expect(content).toBeUndefined();
});
it('falls back to project agents dir', async () => {
const projectAgentsDir = join(tempDir, 'project-agents');
await mkdir(projectAgentsDir, { recursive: true });
await writeFile(
join(projectAgentsDir, 'gsd-executor.md'),
'project agent content',
);
const factory = new PromptFactory({
gsdInstallDir: tempDir,
agentsDir,
projectAgentsDir,
sdkPromptsDir: join(tempDir, 'sdk-prompts-does-not-exist'),
});
const content = await factory.loadAgentDef(PhaseType.Execute);
expect(content).toBe('project agent content');
});
it('prefers user agents dir over project agents dir', async () => {
const projectAgentsDir = join(tempDir, 'project-agents');
await mkdir(projectAgentsDir, { recursive: true });
await writeFile(join(agentsDir, 'gsd-executor.md'), 'user agent');
await writeFile(join(projectAgentsDir, 'gsd-executor.md'), 'project agent');
const factory = new PromptFactory({
gsdInstallDir: tempDir,
agentsDir,
projectAgentsDir,
sdkPromptsDir: join(tempDir, 'sdk-prompts-does-not-exist'),
});
const content = await factory.loadAgentDef(PhaseType.Execute);
expect(content).toBe('user agent');
});
});
// ─── Headless prompt loading ─────────────────────────────────────────────
describe('headless prompt loading', () => {
it('loadWorkflowFile prefers installed GSD over sdkPromptsDir', async () => {
const sdkDir = join(tempDir, 'sdk-prompts');
await mkdir(join(sdkDir, 'workflows'), { recursive: true });
// Write both: installed GSD and SDK bundled version
await writeFile(join(workflowsDir, 'research-phase.md'), 'GSD-1 original');
await writeFile(join(sdkDir, 'workflows', 'research-phase.md'), 'SDK bundled version');
const factory = new PromptFactory({
gsdInstallDir: tempDir,
agentsDir,
sdkPromptsDir: sdkDir,
});
const content = await factory.loadWorkflowFile(PhaseType.Research);
expect(content).toBe('GSD-1 original');
});
it('loadWorkflowFile falls back to GSD-1 when sdkPromptsDir file missing', async () => {
const sdkDir = join(tempDir, 'sdk-prompts');
await mkdir(join(sdkDir, 'workflows'), { recursive: true });
// Only GSD-1 original exists, no SDK version
await writeFile(join(workflowsDir, 'research-phase.md'), 'GSD-1 original');
const factory = new PromptFactory({
gsdInstallDir: tempDir,
agentsDir,
sdkPromptsDir: sdkDir,
});
const content = await factory.loadWorkflowFile(PhaseType.Research);
expect(content).toBe('GSD-1 original');
});
it('loadAgentDef prefers installed agents over sdkPromptsDir', async () => {
const sdkDir = join(tempDir, 'sdk-prompts');
await mkdir(join(sdkDir, 'agents'), { recursive: true });
// Write both: installed agent and SDK bundled agent
await writeFile(join(agentsDir, 'gsd-executor.md'), 'user agent');
await writeFile(join(sdkDir, 'agents', 'gsd-executor.md'), 'SDK bundled agent');
const factory = new PromptFactory({
gsdInstallDir: tempDir,
agentsDir,
sdkPromptsDir: sdkDir,
});
const content = await factory.loadAgentDef(PhaseType.Execute);
expect(content).toBe('user agent');
});
it('loadAgentDef falls back to user agents when sdkPromptsDir file missing', async () => {
const sdkDir = join(tempDir, 'sdk-prompts');
await mkdir(join(sdkDir, 'agents'), { recursive: true });
// Only user agent exists, no SDK version
await writeFile(join(agentsDir, 'gsd-executor.md'), 'user agent');
const factory = new PromptFactory({
gsdInstallDir: tempDir,
agentsDir,
sdkPromptsDir: sdkDir,
});
const content = await factory.loadAgentDef(PhaseType.Execute);
expect(content).toBe('user agent');
});
it('buildPrompt sanitizes interactive patterns from output', async () => {
// Use separate lines so non-interactive content survives stripping
await writeFile(
join(workflowsDir, 'research-phase.md'),
makeWorkflowContent('Research the codebase thoroughly.', [
'Gather data from the project.\nAskUserQuestion("what?")\nAnalyze findings.',
'Run the analysis.\n/gsd:analyze --deep\nDocument results.',
]),
);
await writeFile(
join(agentsDir, 'gsd-phase-researcher.md'),
makeAgentDef('gsd-phase-researcher', 'Read, Bash', 'You are a researcher.\nSTOP and wait for user input.\nBe thorough.'),
);
const factory = makeFactory();
const contextFiles: ContextFiles = { state: '# State' };
const prompt = await factory.buildPrompt(PhaseType.Research, null, contextFiles);
// Interactive patterns should be stripped by sanitizePrompt()
expect(prompt).not.toContain('AskUserQuestion');
expect(prompt).not.toContain('/gsd:');
expect(prompt).not.toMatch(/\bSTOP\s+and\s+wait/);
// Non-interactive content on separate lines should remain
expect(prompt).toContain('You are a researcher.');
expect(prompt).toContain('Be thorough.');
expect(prompt).toContain('Gather data from the project.');
expect(prompt).toContain('Analyze findings.');
});
it('buildPrompt with execute+plan sanitizes output from buildExecutorPrompt', async () => {
await writeFile(
join(agentsDir, 'gsd-executor.md'),
makeAgentDef('gsd-executor', 'Read, Write, Edit, Bash', 'You are an executor.\nSTOP and wait for user.\nExecute thoroughly.'),
);
const factory = makeFactory();
const plan = makeParsedPlan({ objective: 'Build the auth system' });
const contextFiles: ContextFiles = { state: '# State' };
const prompt = await factory.buildPrompt(PhaseType.Execute, plan, contextFiles);
// Objective should remain (no interactive pattern on that line)
expect(prompt).toContain('Build the auth system');
// The role's STOP directive should be stripped
expect(prompt).not.toMatch(/\bSTOP\s+and\s+wait/);
// Non-interactive role content should remain
expect(prompt).toContain('You are an executor.');
});
});
});
describe('PHASE_WORKFLOW_MAP', () => {
it('maps all phase types to workflow filenames', () => {
for (const phase of Object.values(PhaseType)) {
expect(PHASE_WORKFLOW_MAP[phase]).toBeDefined();
expect(PHASE_WORKFLOW_MAP[phase]).toMatch(/\.md$/);
}
});
it('execute phase maps to execute-plan.md (not execute-phase.md)', () => {
expect(PHASE_WORKFLOW_MAP[PhaseType.Execute]).toBe('execute-plan.md');
});
});

View File

@@ -1,421 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { GSDTools, GSDToolsError } from './gsd-tools.js';
import {
PhaseStepType,
GSDEventType,
PhaseType,
type PhaseOpInfo,
type PhaseStepResult,
type PhaseRunnerResult,
type HumanGateCallbacks,
type PhaseRunnerOptions,
type GSDPhaseStartEvent,
type GSDPhaseStepStartEvent,
type GSDPhaseStepCompleteEvent,
type GSDPhaseCompleteEvent,
} from './types.js';
import { mkdir, writeFile, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
describe('Phase lifecycle types', () => {
// ─── PhaseStepType enum ────────────────────────────────────────────────
describe('PhaseStepType', () => {
it('has all expected step values', () => {
expect(PhaseStepType.Discuss).toBe('discuss');
expect(PhaseStepType.Research).toBe('research');
expect(PhaseStepType.Plan).toBe('plan');
expect(PhaseStepType.Execute).toBe('execute');
expect(PhaseStepType.Verify).toBe('verify');
expect(PhaseStepType.Advance).toBe('advance');
});
it('has exactly 7 members', () => {
const values = Object.values(PhaseStepType);
expect(values).toHaveLength(7);
});
});
// ─── GSDEventType phase lifecycle values ───────────────────────────────
describe('GSDEventType phase lifecycle events', () => {
it('includes PhaseStart', () => {
expect(GSDEventType.PhaseStart).toBe('phase_start');
});
it('includes PhaseStepStart', () => {
expect(GSDEventType.PhaseStepStart).toBe('phase_step_start');
});
it('includes PhaseStepComplete', () => {
expect(GSDEventType.PhaseStepComplete).toBe('phase_step_complete');
});
it('includes PhaseComplete', () => {
expect(GSDEventType.PhaseComplete).toBe('phase_complete');
});
});
// ─── PhaseOpInfo shape validation ──────────────────────────────────────
describe('PhaseOpInfo interface', () => {
it('accepts a valid phase-op output object', () => {
const info: PhaseOpInfo = {
phase_found: true,
phase_dir: '.planning/phases/05-Skill-Scaffolding',
phase_number: '5',
phase_name: 'Skill Scaffolding',
phase_slug: 'skill-scaffolding',
padded_phase: '05',
has_research: false,
has_context: false,
has_plans: false,
has_verification: false,
plan_count: 0,
roadmap_exists: true,
planning_exists: true,
commit_docs: true,
context_path: '.planning/phases/05-Skill-Scaffolding/CONTEXT.md',
research_path: '.planning/phases/05-Skill-Scaffolding/RESEARCH.md',
};
expect(info.phase_found).toBe(true);
expect(info.phase_number).toBe('5');
expect(info.plan_count).toBe(0);
expect(info.has_context).toBe(false);
});
it('matches the documented init phase-op JSON shape', () => {
// Simulate parsing JSON from gsd-tools.cjs
const raw = JSON.parse(JSON.stringify({
phase_found: true,
phase_dir: '.planning/phases/03-Auth',
phase_number: '3',
phase_name: 'Auth',
phase_slug: 'auth',
padded_phase: '03',
has_research: true,
has_context: true,
has_plans: true,
has_verification: false,
plan_count: 2,
roadmap_exists: true,
planning_exists: true,
commit_docs: true,
context_path: '.planning/phases/03-Auth/CONTEXT.md',
research_path: '.planning/phases/03-Auth/RESEARCH.md',
}));
const info = raw as PhaseOpInfo;
expect(info.phase_found).toBe(true);
expect(info.has_plans).toBe(true);
expect(info.plan_count).toBe(2);
expect(typeof info.phase_dir).toBe('string');
expect(typeof info.padded_phase).toBe('string');
});
});
// ─── Phase result types ────────────────────────────────────────────────
describe('PhaseStepResult', () => {
it('can represent a successful step', () => {
const result: PhaseStepResult = {
step: PhaseStepType.Research,
success: true,
durationMs: 5000,
};
expect(result.success).toBe(true);
expect(result.error).toBeUndefined();
});
it('can represent a failed step with error', () => {
const result: PhaseStepResult = {
step: PhaseStepType.Execute,
success: false,
durationMs: 12000,
error: 'Session timed out',
planResults: [],
};
expect(result.success).toBe(false);
expect(result.error).toBe('Session timed out');
});
});
describe('PhaseRunnerResult', () => {
it('can represent a complete phase run', () => {
const result: PhaseRunnerResult = {
phaseNumber: '3',
phaseName: 'Auth',
steps: [
{ step: PhaseStepType.Research, success: true, durationMs: 5000 },
{ step: PhaseStepType.Plan, success: true, durationMs: 3000 },
{ step: PhaseStepType.Execute, success: true, durationMs: 60000 },
],
success: true,
totalCostUsd: 1.5,
totalDurationMs: 68000,
};
expect(result.steps).toHaveLength(3);
expect(result.success).toBe(true);
});
});
describe('HumanGateCallbacks', () => {
it('accepts an object with all optional callbacks', () => {
const callbacks: HumanGateCallbacks = {
onDiscussApproval: async () => 'approve',
onVerificationReview: async () => 'accept',
onBlockerDecision: async () => 'retry',
};
expect(callbacks.onDiscussApproval).toBeDefined();
});
it('accepts an empty object (all callbacks optional)', () => {
const callbacks: HumanGateCallbacks = {};
expect(callbacks.onDiscussApproval).toBeUndefined();
});
});
describe('PhaseRunnerOptions', () => {
it('accepts full options', () => {
const options: PhaseRunnerOptions = {
callbacks: {},
maxBudgetPerStep: 3.0,
maxTurnsPerStep: 30,
model: 'claude-sonnet-4-6',
};
expect(options.maxBudgetPerStep).toBe(3.0);
});
it('accepts empty options (all fields optional)', () => {
const options: PhaseRunnerOptions = {};
expect(options.callbacks).toBeUndefined();
});
});
// ─── Phase lifecycle event interfaces ──────────────────────────────────
describe('Phase lifecycle event interfaces', () => {
it('GSDPhaseStartEvent has correct shape', () => {
const event: GSDPhaseStartEvent = {
type: GSDEventType.PhaseStart,
timestamp: new Date().toISOString(),
sessionId: 'test-session',
phaseNumber: '3',
phaseName: 'Auth',
};
expect(event.type).toBe('phase_start');
expect(event.phaseNumber).toBe('3');
});
it('GSDPhaseStepStartEvent has correct shape', () => {
const event: GSDPhaseStepStartEvent = {
type: GSDEventType.PhaseStepStart,
timestamp: new Date().toISOString(),
sessionId: 'test-session',
phaseNumber: '3',
step: PhaseStepType.Research,
};
expect(event.type).toBe('phase_step_start');
expect(event.step).toBe('research');
});
it('GSDPhaseStepCompleteEvent has correct shape', () => {
const event: GSDPhaseStepCompleteEvent = {
type: GSDEventType.PhaseStepComplete,
timestamp: new Date().toISOString(),
sessionId: 'test-session',
phaseNumber: '3',
step: PhaseStepType.Execute,
success: true,
durationMs: 45000,
};
expect(event.type).toBe('phase_step_complete');
expect(event.success).toBe(true);
});
it('GSDPhaseStepCompleteEvent can include error', () => {
const event: GSDPhaseStepCompleteEvent = {
type: GSDEventType.PhaseStepComplete,
timestamp: new Date().toISOString(),
sessionId: 'test-session',
phaseNumber: '3',
step: PhaseStepType.Verify,
success: false,
durationMs: 2000,
error: 'Verification failed',
};
expect(event.error).toBe('Verification failed');
});
it('GSDPhaseCompleteEvent has correct shape', () => {
const event: GSDPhaseCompleteEvent = {
type: GSDEventType.PhaseComplete,
timestamp: new Date().toISOString(),
sessionId: 'test-session',
phaseNumber: '3',
phaseName: 'Auth',
success: true,
totalCostUsd: 2.5,
totalDurationMs: 120000,
stepsCompleted: 5,
};
expect(event.type).toBe('phase_complete');
expect(event.stepsCompleted).toBe(5);
});
});
});
// ─── GSDTools typed methods ──────────────────────────────────────────────────
describe('GSDTools typed methods', () => {
let tmpDir: string;
let fixtureDir: string;
beforeEach(async () => {
tmpDir = join(tmpdir(), `gsd-tools-phase-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
fixtureDir = join(tmpDir, 'fixtures');
await mkdir(fixtureDir, { recursive: true });
await mkdir(join(tmpDir, '.planning'), { recursive: true });
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
async function createScript(name: string, code: string): Promise<string> {
const scriptPath = join(fixtureDir, name);
await writeFile(scriptPath, code, { mode: 0o755 });
return scriptPath;
}
describe('initPhaseOp()', () => {
it('returns typed PhaseOpInfo from gsd-tools output', async () => {
const mockOutput: PhaseOpInfo = {
phase_found: true,
phase_dir: '.planning/phases/05-Skill-Scaffolding',
phase_number: '5',
phase_name: 'Skill Scaffolding',
phase_slug: 'skill-scaffolding',
padded_phase: '05',
has_research: false,
has_context: true,
has_plans: true,
has_verification: false,
plan_count: 3,
roadmap_exists: true,
planning_exists: true,
commit_docs: true,
context_path: '.planning/phases/05-Skill-Scaffolding/CONTEXT.md',
research_path: '.planning/phases/05-Skill-Scaffolding/RESEARCH.md',
};
const scriptPath = await createScript(
'init-phase-op.cjs',
`
const args = process.argv.slice(2);
// Script receives: init phase-op 5 --raw
if (args[0] === 'init' && args[1] === 'phase-op' && args[2] === '5') {
process.stdout.write(JSON.stringify(${JSON.stringify(mockOutput)}));
} else {
process.stderr.write('unexpected args: ' + args.join(' '));
process.exit(1);
}
`,
);
const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false });
const result = await tools.initPhaseOp('5');
expect(result.phase_found).toBe(true);
expect(result.phase_number).toBe('5');
expect(result.phase_name).toBe('Skill Scaffolding');
expect(result.plan_count).toBe(3);
expect(result.has_context).toBe(true);
expect(result.has_plans).toBe(true);
expect(result.context_path).toContain('CONTEXT.md');
});
it('calls exec with correct args (init phase-op <N>)', async () => {
const scriptPath = await createScript(
'init-phase-op-args.cjs',
`
const args = process.argv.slice(2);
process.stdout.write(JSON.stringify({ received_args: args }));
`,
);
const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false });
const result = await tools.initPhaseOp('7') as { received_args: string[] };
expect(result.received_args).toContain('init');
expect(result.received_args).toContain('phase-op');
expect(result.received_args).toContain('7');
// exec() no longer appends --raw (only execRaw does)
expect(result.received_args).not.toContain('--raw');
});
});
describe('configGet()', () => {
it('returns string value from gsd-tools config', async () => {
const scriptPath = await createScript(
'config-get.cjs',
`
const args = process.argv.slice(2);
if (args[0] === 'config-get' && args[1] === 'model_profile') {
process.stdout.write(JSON.stringify('balanced'));
} else {
process.exit(1);
}
`,
);
const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false });
const result = await tools.configGet('model_profile');
expect(result).toBe('balanced');
});
it('returns null when key not found', async () => {
const scriptPath = await createScript(
'config-get-null.cjs',
`
const args = process.argv.slice(2);
if (args[0] === 'config-get' && args[1] === 'nonexistent_key') {
process.stdout.write('null');
} else {
process.exit(1);
}
`,
);
const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false });
const result = await tools.configGet('nonexistent_key');
expect(result).toBeNull();
});
});
describe('stateBeginPhase()', () => {
it('calls state begin-phase with correct args', async () => {
const scriptPath = await createScript(
'state-begin-phase.cjs',
`
const args = process.argv.slice(2);
if (args[0] === 'state' && args[1] === 'begin-phase' && args[2] === '--phase' && args[3] === '3') {
process.stdout.write('ok');
} else {
process.stderr.write('unexpected args: ' + args.join(' '));
process.exit(1);
}
`,
);
const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false });
const result = await tools.stateBeginPhase('3');
expect(result).toBe('ok');
});
});
});

View File

@@ -1,3 +1,14 @@
/**
* Phase Runner tests — consolidated from phase-runner.test.ts,
* phase-runner-types.test.ts, and phase-prompt.test.ts (issue #3740).
*
* Covers:
* • PhaseRunner state machine (mocked deps)
* • Phase lifecycle type contracts (PhaseStepType, GSDEventType, PhaseOpInfo, etc.)
* • GSDTools typed methods
* • PromptFactory / extractBlock / extractSteps / PHASE_WORKFLOW_MAP
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mkdtemp, mkdir, writeFile, rm, symlink } from 'node:fs/promises';
import { join } from 'node:path';
@@ -13,10 +24,22 @@ import type {
GSDEvent,
PhasePlanIndex,
PlanInfo,
PhaseStepResult,
PhaseRunnerResult,
PhaseRunnerOptions,
GSDPhaseStartEvent,
GSDPhaseStepStartEvent,
GSDPhaseStepCompleteEvent,
GSDPhaseCompleteEvent,
ContextFiles,
ParsedPlan,
PlanFrontmatter,
} from './types.js';
import { PhaseStepType, PhaseType, GSDEventType } from './types.js';
import type { GSDConfig } from './config.js';
import { CONFIG_DEFAULTS } from './config.js';
import { GSDTools, GSDToolsError } from './gsd-tools.js';
import { PromptFactory, extractBlock, extractSteps, PHASE_WORKFLOW_MAP } from './phase-prompt.js';
// ─── Mock modules ────────────────────────────────────────────────────────────
@@ -2718,3 +2741,920 @@ Use TypeScript.`, 'utf-8');
});
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Phase lifecycle type contracts
// (consolidated from sdk/src/phase-runner-types.test.ts — issue #3740)
// ─────────────────────────────────────────────────────────────────────────────
describe('Phase lifecycle types', () => {
// ─── PhaseStepType enum ────────────────────────────────────────────────
describe('PhaseStepType', () => {
it('has all expected step values', () => {
expect(PhaseStepType.Discuss).toBe('discuss');
expect(PhaseStepType.Research).toBe('research');
expect(PhaseStepType.Plan).toBe('plan');
expect(PhaseStepType.Execute).toBe('execute');
expect(PhaseStepType.Verify).toBe('verify');
expect(PhaseStepType.Advance).toBe('advance');
});
it('has exactly 7 members', () => {
const values = Object.values(PhaseStepType);
expect(values).toHaveLength(7);
});
});
// ─── GSDEventType phase lifecycle values ───────────────────────────────
describe('GSDEventType phase lifecycle events', () => {
it('includes PhaseStart', () => {
expect(GSDEventType.PhaseStart).toBe('phase_start');
});
it('includes PhaseStepStart', () => {
expect(GSDEventType.PhaseStepStart).toBe('phase_step_start');
});
it('includes PhaseStepComplete', () => {
expect(GSDEventType.PhaseStepComplete).toBe('phase_step_complete');
});
it('includes PhaseComplete', () => {
expect(GSDEventType.PhaseComplete).toBe('phase_complete');
});
});
// ─── PhaseOpInfo shape validation ──────────────────────────────────────
describe('PhaseOpInfo interface', () => {
it('accepts a valid phase-op output object', () => {
const info: PhaseOpInfo = {
phase_found: true,
phase_dir: '.planning/phases/05-Skill-Scaffolding',
phase_number: '5',
phase_name: 'Skill Scaffolding',
phase_slug: 'skill-scaffolding',
padded_phase: '05',
has_research: false,
has_context: false,
has_plans: false,
has_verification: false,
plan_count: 0,
roadmap_exists: true,
planning_exists: true,
commit_docs: true,
context_path: '.planning/phases/05-Skill-Scaffolding/CONTEXT.md',
research_path: '.planning/phases/05-Skill-Scaffolding/RESEARCH.md',
};
expect(info.phase_found).toBe(true);
expect(info.phase_number).toBe('5');
expect(info.plan_count).toBe(0);
expect(info.has_context).toBe(false);
});
it('matches the documented init phase-op JSON shape', () => {
const raw = JSON.parse(JSON.stringify({
phase_found: true,
phase_dir: '.planning/phases/03-Auth',
phase_number: '3',
phase_name: 'Auth',
phase_slug: 'auth',
padded_phase: '03',
has_research: true,
has_context: true,
has_plans: true,
has_verification: false,
plan_count: 2,
roadmap_exists: true,
planning_exists: true,
commit_docs: true,
context_path: '.planning/phases/03-Auth/CONTEXT.md',
research_path: '.planning/phases/03-Auth/RESEARCH.md',
}));
const info = raw as PhaseOpInfo;
expect(info.phase_found).toBe(true);
expect(info.has_plans).toBe(true);
expect(info.plan_count).toBe(2);
expect(typeof info.phase_dir).toBe('string');
expect(typeof info.padded_phase).toBe('string');
});
});
// ─── Phase result types ────────────────────────────────────────────────
describe('PhaseStepResult', () => {
it('can represent a successful step', () => {
const result: PhaseStepResult = {
step: PhaseStepType.Research,
success: true,
durationMs: 5000,
};
expect(result.success).toBe(true);
expect(result.error).toBeUndefined();
});
it('can represent a failed step with error', () => {
const result: PhaseStepResult = {
step: PhaseStepType.Execute,
success: false,
durationMs: 12000,
error: 'Session timed out',
planResults: [],
};
expect(result.success).toBe(false);
expect(result.error).toBe('Session timed out');
});
});
describe('PhaseRunnerResult', () => {
it('can represent a complete phase run', () => {
const result: PhaseRunnerResult = {
phaseNumber: '3',
phaseName: 'Auth',
steps: [
{ step: PhaseStepType.Research, success: true, durationMs: 5000 },
{ step: PhaseStepType.Plan, success: true, durationMs: 3000 },
{ step: PhaseStepType.Execute, success: true, durationMs: 60000 },
],
success: true,
totalCostUsd: 1.5,
totalDurationMs: 68000,
};
expect(result.steps).toHaveLength(3);
expect(result.success).toBe(true);
});
});
describe('HumanGateCallbacks', () => {
it('accepts an object with all optional callbacks', () => {
const callbacks: HumanGateCallbacks = {
onDiscussApproval: async () => 'approve',
onVerificationReview: async () => 'accept',
onBlockerDecision: async () => 'retry',
};
expect(callbacks.onDiscussApproval).toBeDefined();
});
it('accepts an empty object (all callbacks optional)', () => {
const callbacks: HumanGateCallbacks = {};
expect(callbacks.onDiscussApproval).toBeUndefined();
});
});
describe('PhaseRunnerOptions', () => {
it('accepts full options', () => {
const options: PhaseRunnerOptions = {
callbacks: {},
maxBudgetPerStep: 3.0,
maxTurnsPerStep: 30,
model: 'claude-sonnet-4-6',
};
expect(options.maxBudgetPerStep).toBe(3.0);
});
it('accepts empty options (all fields optional)', () => {
const options: PhaseRunnerOptions = {};
expect(options.callbacks).toBeUndefined();
});
});
// ─── Phase lifecycle event interfaces ──────────────────────────────────
describe('Phase lifecycle event interfaces', () => {
it('GSDPhaseStartEvent has correct shape', () => {
const event: GSDPhaseStartEvent = {
type: GSDEventType.PhaseStart,
timestamp: new Date().toISOString(),
sessionId: 'test-session',
phaseNumber: '3',
phaseName: 'Auth',
};
expect(event.type).toBe('phase_start');
expect(event.phaseNumber).toBe('3');
});
it('GSDPhaseStepStartEvent has correct shape', () => {
const event: GSDPhaseStepStartEvent = {
type: GSDEventType.PhaseStepStart,
timestamp: new Date().toISOString(),
sessionId: 'test-session',
phaseNumber: '3',
step: PhaseStepType.Research,
};
expect(event.type).toBe('phase_step_start');
expect(event.step).toBe('research');
});
it('GSDPhaseStepCompleteEvent has correct shape', () => {
const event: GSDPhaseStepCompleteEvent = {
type: GSDEventType.PhaseStepComplete,
timestamp: new Date().toISOString(),
sessionId: 'test-session',
phaseNumber: '3',
step: PhaseStepType.Execute,
success: true,
durationMs: 45000,
};
expect(event.type).toBe('phase_step_complete');
expect(event.success).toBe(true);
});
it('GSDPhaseStepCompleteEvent can include error', () => {
const event: GSDPhaseStepCompleteEvent = {
type: GSDEventType.PhaseStepComplete,
timestamp: new Date().toISOString(),
sessionId: 'test-session',
phaseNumber: '3',
step: PhaseStepType.Verify,
success: false,
durationMs: 2000,
error: 'Verification failed',
};
expect(event.error).toBe('Verification failed');
});
it('GSDPhaseCompleteEvent has correct shape', () => {
const event: GSDPhaseCompleteEvent = {
type: GSDEventType.PhaseComplete,
timestamp: new Date().toISOString(),
sessionId: 'test-session',
phaseNumber: '3',
phaseName: 'Auth',
success: true,
totalCostUsd: 2.5,
totalDurationMs: 120000,
stepsCompleted: 5,
};
expect(event.type).toBe('phase_complete');
expect(event.stepsCompleted).toBe(5);
});
});
});
// ─── GSDTools typed methods ──────────────────────────────────────────────────
describe('GSDTools typed methods', () => {
let tmpDir: string;
let fixtureDir: string;
beforeEach(async () => {
tmpDir = join(tmpdir(), `gsd-tools-phase-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
fixtureDir = join(tmpDir, 'fixtures');
await mkdir(fixtureDir, { recursive: true });
await mkdir(join(tmpDir, '.planning'), { recursive: true });
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
async function createScript(name: string, code: string): Promise<string> {
const scriptPath = join(fixtureDir, name);
await writeFile(scriptPath, code, { mode: 0o755 });
return scriptPath;
}
describe('initPhaseOp()', () => {
it('returns typed PhaseOpInfo from gsd-tools output', async () => {
const mockOutput: PhaseOpInfo = {
phase_found: true,
phase_dir: '.planning/phases/05-Skill-Scaffolding',
phase_number: '5',
phase_name: 'Skill Scaffolding',
phase_slug: 'skill-scaffolding',
padded_phase: '05',
has_research: false,
has_context: true,
has_plans: true,
has_verification: false,
plan_count: 3,
roadmap_exists: true,
planning_exists: true,
commit_docs: true,
context_path: '.planning/phases/05-Skill-Scaffolding/CONTEXT.md',
research_path: '.planning/phases/05-Skill-Scaffolding/RESEARCH.md',
};
const scriptPath = await createScript(
'init-phase-op.cjs',
`
const args = process.argv.slice(2);
// Script receives: init phase-op 5 --raw
if (args[0] === 'init' && args[1] === 'phase-op' && args[2] === '5') {
process.stdout.write(JSON.stringify(${JSON.stringify(mockOutput)}));
} else {
process.stderr.write('unexpected args: ' + args.join(' '));
process.exit(1);
}
`,
);
const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false });
const result = await tools.initPhaseOp('5');
expect(result.phase_found).toBe(true);
expect(result.phase_number).toBe('5');
expect(result.phase_name).toBe('Skill Scaffolding');
expect(result.plan_count).toBe(3);
expect(result.has_context).toBe(true);
expect(result.has_plans).toBe(true);
expect(result.context_path).toContain('CONTEXT.md');
});
it('calls exec with correct args (init phase-op <N>)', async () => {
const scriptPath = await createScript(
'init-phase-op-args.cjs',
`
const args = process.argv.slice(2);
process.stdout.write(JSON.stringify({ received_args: args }));
`,
);
const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false });
const result = await tools.initPhaseOp('7') as { received_args: string[] };
expect(result.received_args).toContain('init');
expect(result.received_args).toContain('phase-op');
expect(result.received_args).toContain('7');
expect(result.received_args).not.toContain('--raw');
});
});
describe('configGet()', () => {
it('returns string value from gsd-tools config', async () => {
const scriptPath = await createScript(
'config-get.cjs',
`
const args = process.argv.slice(2);
if (args[0] === 'config-get' && args[1] === 'model_profile') {
process.stdout.write(JSON.stringify('balanced'));
} else {
process.exit(1);
}
`,
);
const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false });
const result = await tools.configGet('model_profile');
expect(result).toBe('balanced');
});
it('returns null when key not found', async () => {
const scriptPath = await createScript(
'config-get-null.cjs',
`
const args = process.argv.slice(2);
if (args[0] === 'config-get' && args[1] === 'nonexistent_key') {
process.stdout.write('null');
} else {
process.exit(1);
}
`,
);
const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false });
const result = await tools.configGet('nonexistent_key');
expect(result).toBeNull();
});
});
describe('stateBeginPhase()', () => {
it('calls state begin-phase with correct args', async () => {
const scriptPath = await createScript(
'state-begin-phase.cjs',
`
const args = process.argv.slice(2);
if (args[0] === 'state' && args[1] === 'begin-phase' && args[2] === '--phase' && args[3] === '3') {
process.stdout.write('ok');
} else {
process.stderr.write('unexpected args: ' + args.join(' '));
process.exit(1);
}
`,
);
const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false });
const result = await tools.stateBeginPhase('3');
expect(result).toBe('ok');
});
});
});
// ─────────────────────────────────────────────────────────────────────────────
// PromptFactory / extractBlock / extractSteps / PHASE_WORKFLOW_MAP
// (consolidated from sdk/src/phase-prompt.test.ts — issue #3740)
// ─────────────────────────────────────────────────────────────────────────────
// ─── Prompt helpers ───────────────────────────────────────────────────────────
async function createPromptTempDir(): Promise<string> {
return mkdtemp(join(tmpdir(), 'gsd-prompt-'));
}
function makeWorkflowContent(purpose: string, steps: string[]): string {
const stepBlocks = steps
.map((s, i) => `<step name="step_${i + 1}">\n${s}\n</step>`)
.join('\n\n');
return `<purpose>\n${purpose}\n</purpose>\n\n<process>\n${stepBlocks}\n</process>`;
}
function makeAgentDef(name: string, tools: string, role: string): string {
return `---\nname: ${name}\ntools: ${tools}\n---\n\n<role>\n${role}\n</role>`;
}
function makePromptParsedPlan(overrides?: Partial<ParsedPlan>): ParsedPlan {
return {
frontmatter: {
phase: 'execute',
plan: 'test-plan',
type: 'feature',
wave: 1,
depends_on: [],
files_modified: [],
autonomous: true,
requirements: [],
must_haves: { truths: [], artifacts: [], key_links: [] },
} as PlanFrontmatter,
objective: 'Test objective',
execution_context: [],
context_refs: [],
tasks: [],
raw: '',
...overrides,
};
}
// ─── extractBlock tests ──────────────────────────────────────────────────────
describe('extractBlock', () => {
it('extracts content from a simple block', () => {
const content = '<purpose>\nDo the thing.\n</purpose>';
expect(extractBlock(content, 'purpose')).toBe('Do the thing.');
});
it('extracts content from block with attributes', () => {
const content = '<step name="init" priority="first">\nLoad context.\n</step>';
expect(extractBlock(content, 'step')).toBe('Load context.');
});
it('returns empty string for missing block', () => {
const content = '<purpose>Something</purpose>';
expect(extractBlock(content, 'role')).toBe('');
});
it('extracts multiline content', () => {
const content = '<role>\nLine 1\nLine 2\nLine 3\n</role>';
expect(extractBlock(content, 'role')).toBe('Line 1\nLine 2\nLine 3');
});
});
describe('extractSteps', () => {
it('extracts multiple steps from process content', () => {
const process = `
<step name="init">Initialize</step>
<step name="execute">Run tasks</step>
<step name="verify">Check results</step>`;
const steps = extractSteps(process);
expect(steps).toHaveLength(3);
expect(steps[0]).toEqual({ name: 'init', content: 'Initialize' });
expect(steps[1]).toEqual({ name: 'execute', content: 'Run tasks' });
expect(steps[2]).toEqual({ name: 'verify', content: 'Check results' });
});
it('returns empty array for no steps', () => {
expect(extractSteps('no steps here')).toEqual([]);
});
it('handles steps with priority attributes', () => {
const process = '<step name="init" priority="first">\nDo first.\n</step>';
const steps = extractSteps(process);
expect(steps).toHaveLength(1);
expect(steps[0].name).toBe('init');
expect(steps[0].content).toBe('Do first.');
});
});
// ─── PromptFactory tests ─────────────────────────────────────────────────────
describe('PromptFactory', () => {
let tempDir: string;
let workflowsDir: string;
let agentsDir: string;
beforeEach(async () => {
tempDir = await createPromptTempDir();
workflowsDir = join(tempDir, 'workflows');
agentsDir = join(tempDir, 'agents');
await mkdir(workflowsDir, { recursive: true });
await mkdir(agentsDir, { recursive: true });
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
function makeFactory(): PromptFactory {
return new PromptFactory({
gsdInstallDir: tempDir,
agentsDir,
sdkPromptsDir: join(tempDir, 'sdk-prompts-does-not-exist'),
});
}
describe('buildPrompt', () => {
it('assembles research prompt with role + purpose + process + context', async () => {
await writeFile(
join(workflowsDir, 'research-phase.md'),
makeWorkflowContent('Research the phase.', ['Gather info', 'Analyze findings']),
);
await writeFile(
join(agentsDir, 'gsd-phase-researcher.md'),
makeAgentDef('gsd-phase-researcher', 'Read, Grep, Bash', 'You are a researcher.'),
);
const factory = makeFactory();
const contextFiles: ContextFiles = {
state: '# State\nproject: test',
roadmap: '# Roadmap\nphases listed',
};
const prompt = await factory.buildPrompt(PhaseType.Research, null, contextFiles);
expect(prompt).toContain('## Agent Instructions');
expect(prompt).toContain('You are a researcher.');
expect(prompt).toContain('## Purpose');
expect(prompt).toContain('Research the phase.');
expect(prompt).toContain('## Process');
expect(prompt).toContain('Gather info');
expect(prompt).toContain('## Context');
expect(prompt).toContain('# State');
expect(prompt).toContain('# Roadmap');
const agentIdx = prompt.indexOf('## Agent Instructions');
const contextIdx = prompt.indexOf('## Context');
expect(agentIdx).toBeLessThan(contextIdx);
});
it('assembles plan prompt with all context files', async () => {
await writeFile(
join(workflowsDir, 'plan-phase.md'),
makeWorkflowContent('Plan the implementation.', ['Break down tasks']),
);
await writeFile(
join(agentsDir, 'gsd-planner.md'),
makeAgentDef('gsd-planner', 'Read, Write, Bash', 'You are a planner.'),
);
const factory = makeFactory();
const contextFiles: ContextFiles = {
state: '# State',
roadmap: '# Roadmap',
context: '# Context',
research: '# Research',
requirements: '# Requirements',
};
const prompt = await factory.buildPrompt(PhaseType.Plan, null, contextFiles);
expect(prompt).toContain('You are a planner.');
expect(prompt).toContain('Plan the implementation.');
expect(prompt).toContain('# State');
expect(prompt).toContain('# Research');
expect(prompt).toContain('# Requirements');
});
it('delegates execute phase with plan to buildExecutorPrompt', async () => {
await writeFile(
join(agentsDir, 'gsd-executor.md'),
makeAgentDef('gsd-executor', 'Read, Write, Edit, Bash', 'You are an executor.'),
);
const factory = makeFactory();
const plan = makePromptParsedPlan({ objective: 'Build the auth system' });
const contextFiles: ContextFiles = { state: '# State' };
const prompt = await factory.buildPrompt(PhaseType.Execute, plan, contextFiles);
expect(prompt).toContain('## Objective');
expect(prompt).toContain('Build the auth system');
expect(prompt).toContain('## Role');
expect(prompt).toContain('You are an executor.');
});
it('handles execute phase without plan (non-delegation path)', async () => {
await writeFile(
join(workflowsDir, 'execute-plan.md'),
makeWorkflowContent('Execute the plan.', ['Run tasks']),
);
await writeFile(
join(agentsDir, 'gsd-executor.md'),
makeAgentDef('gsd-executor', 'Read, Write, Edit, Bash', 'You are an executor.'),
);
const factory = makeFactory();
const contextFiles: ContextFiles = { state: '# State' };
const prompt = await factory.buildPrompt(PhaseType.Execute, null, contextFiles);
expect(prompt).toContain('## Agent Instructions');
expect(prompt).toContain('You are an executor.');
expect(prompt).toContain('## Purpose');
expect(prompt).toContain('Execute the plan.');
});
it('assembles verify prompt with phase instructions', async () => {
await writeFile(
join(workflowsDir, 'verify-phase.md'),
makeWorkflowContent('Verify phase goals.', ['Check artifacts', 'Run tests']),
);
await writeFile(
join(agentsDir, 'gsd-verifier.md'),
makeAgentDef('gsd-verifier', 'Read, Bash, Grep', 'You are a verifier.'),
);
const factory = makeFactory();
const contextFiles: ContextFiles = {
state: '# State',
roadmap: '# Roadmap',
requirements: '# Requirements',
};
const prompt = await factory.buildPrompt(PhaseType.Verify, null, contextFiles);
expect(prompt).toContain('You are a verifier.');
expect(prompt).toContain('Verify phase goals.');
});
it('assembles discuss prompt without agent role (no dedicated agent)', async () => {
await writeFile(
join(workflowsDir, 'discuss-phase.md'),
makeWorkflowContent('Discuss implementation decisions.', ['Identify areas']),
);
const factory = makeFactory();
const contextFiles: ContextFiles = { state: '# State' };
const prompt = await factory.buildPrompt(PhaseType.Discuss, null, contextFiles);
expect(prompt).not.toContain('## Agent Instructions');
expect(prompt).toContain('## Purpose');
expect(prompt).toContain('Discuss implementation decisions.');
});
it('handles missing workflow file gracefully', async () => {
await writeFile(
join(agentsDir, 'gsd-phase-researcher.md'),
makeAgentDef('gsd-phase-researcher', 'Read, Bash', 'You are a researcher.'),
);
const factory = makeFactory();
const contextFiles: ContextFiles = { state: '# State' };
const prompt = await factory.buildPrompt(PhaseType.Research, null, contextFiles);
expect(prompt).toContain('## Agent Instructions');
expect(prompt).toContain('## Context');
expect(prompt).not.toContain('## Purpose');
});
it('handles missing agent def gracefully', async () => {
await writeFile(
join(workflowsDir, 'research-phase.md'),
makeWorkflowContent('Research the phase.', ['Gather info']),
);
const factory = makeFactory();
const contextFiles: ContextFiles = { state: '# State' };
const prompt = await factory.buildPrompt(PhaseType.Research, null, contextFiles);
expect(prompt).not.toContain('## Agent Instructions');
expect(prompt).toContain('## Purpose');
expect(prompt).toContain('Research the phase.');
});
it('omits empty context section when no files provided', async () => {
await writeFile(
join(workflowsDir, 'discuss-phase.md'),
makeWorkflowContent('Discuss things.', ['Talk']),
);
const factory = makeFactory();
const contextFiles: ContextFiles = {};
const prompt = await factory.buildPrompt(PhaseType.Discuss, null, contextFiles);
expect(prompt).not.toContain('## Context');
});
});
describe('loadWorkflowFile', () => {
it('loads existing workflow file', async () => {
await writeFile(
join(workflowsDir, 'research-phase.md'),
'workflow content',
);
const factory = makeFactory();
const content = await factory.loadWorkflowFile(PhaseType.Research);
expect(content).toBe('workflow content');
});
it('returns undefined for missing workflow file', async () => {
const factory = makeFactory();
const content = await factory.loadWorkflowFile(PhaseType.Research);
expect(content).toBeUndefined();
});
});
describe('loadAgentDef', () => {
it('loads agent def from agents dir', async () => {
await writeFile(
join(agentsDir, 'gsd-executor.md'),
'agent content',
);
const factory = makeFactory();
const content = await factory.loadAgentDef(PhaseType.Execute);
expect(content).toBe('agent content');
});
it('returns undefined for phases with no agent (discuss)', async () => {
const factory = makeFactory();
const content = await factory.loadAgentDef(PhaseType.Discuss);
expect(content).toBeUndefined();
});
it('falls back to project agents dir', async () => {
const projectAgentsDir = join(tempDir, 'project-agents');
await mkdir(projectAgentsDir, { recursive: true });
await writeFile(
join(projectAgentsDir, 'gsd-executor.md'),
'project agent content',
);
const factory = new PromptFactory({
gsdInstallDir: tempDir,
agentsDir,
projectAgentsDir,
sdkPromptsDir: join(tempDir, 'sdk-prompts-does-not-exist'),
});
const content = await factory.loadAgentDef(PhaseType.Execute);
expect(content).toBe('project agent content');
});
it('prefers user agents dir over project agents dir', async () => {
const projectAgentsDir = join(tempDir, 'project-agents');
await mkdir(projectAgentsDir, { recursive: true });
await writeFile(join(agentsDir, 'gsd-executor.md'), 'user agent');
await writeFile(join(projectAgentsDir, 'gsd-executor.md'), 'project agent');
const factory = new PromptFactory({
gsdInstallDir: tempDir,
agentsDir,
projectAgentsDir,
sdkPromptsDir: join(tempDir, 'sdk-prompts-does-not-exist'),
});
const content = await factory.loadAgentDef(PhaseType.Execute);
expect(content).toBe('user agent');
});
});
// ─── Headless prompt loading ─────────────────────────────────────────────
describe('headless prompt loading', () => {
it('loadWorkflowFile prefers installed GSD over sdkPromptsDir', async () => {
const sdkDir = join(tempDir, 'sdk-prompts');
await mkdir(join(sdkDir, 'workflows'), { recursive: true });
await writeFile(join(workflowsDir, 'research-phase.md'), 'GSD-1 original');
await writeFile(join(sdkDir, 'workflows', 'research-phase.md'), 'SDK bundled version');
const factory = new PromptFactory({
gsdInstallDir: tempDir,
agentsDir,
sdkPromptsDir: sdkDir,
});
const content = await factory.loadWorkflowFile(PhaseType.Research);
expect(content).toBe('GSD-1 original');
});
it('loadWorkflowFile falls back to GSD-1 when sdkPromptsDir file missing', async () => {
const sdkDir = join(tempDir, 'sdk-prompts');
await mkdir(join(sdkDir, 'workflows'), { recursive: true });
await writeFile(join(workflowsDir, 'research-phase.md'), 'GSD-1 original');
const factory = new PromptFactory({
gsdInstallDir: tempDir,
agentsDir,
sdkPromptsDir: sdkDir,
});
const content = await factory.loadWorkflowFile(PhaseType.Research);
expect(content).toBe('GSD-1 original');
});
it('loadAgentDef prefers installed agents over sdkPromptsDir', async () => {
const sdkDir = join(tempDir, 'sdk-prompts');
await mkdir(join(sdkDir, 'agents'), { recursive: true });
await writeFile(join(agentsDir, 'gsd-executor.md'), 'user agent');
await writeFile(join(sdkDir, 'agents', 'gsd-executor.md'), 'SDK bundled agent');
const factory = new PromptFactory({
gsdInstallDir: tempDir,
agentsDir,
sdkPromptsDir: sdkDir,
});
const content = await factory.loadAgentDef(PhaseType.Execute);
expect(content).toBe('user agent');
});
it('loadAgentDef falls back to user agents when sdkPromptsDir file missing', async () => {
const sdkDir = join(tempDir, 'sdk-prompts');
await mkdir(join(sdkDir, 'agents'), { recursive: true });
await writeFile(join(agentsDir, 'gsd-executor.md'), 'user agent');
const factory = new PromptFactory({
gsdInstallDir: tempDir,
agentsDir,
sdkPromptsDir: sdkDir,
});
const content = await factory.loadAgentDef(PhaseType.Execute);
expect(content).toBe('user agent');
});
it('buildPrompt sanitizes interactive patterns from output', async () => {
await writeFile(
join(workflowsDir, 'research-phase.md'),
makeWorkflowContent('Research the codebase thoroughly.', [
'Gather data from the project.\nAskUserQuestion("what?")\nAnalyze findings.',
'Run the analysis.\n/gsd:analyze --deep\nDocument results.',
]),
);
await writeFile(
join(agentsDir, 'gsd-phase-researcher.md'),
makeAgentDef('gsd-phase-researcher', 'Read, Bash', 'You are a researcher.\nSTOP and wait for user input.\nBe thorough.'),
);
const factory = makeFactory();
const contextFiles: ContextFiles = { state: '# State' };
const prompt = await factory.buildPrompt(PhaseType.Research, null, contextFiles);
expect(prompt).not.toContain('AskUserQuestion');
expect(prompt).not.toContain('/gsd:');
expect(prompt).not.toMatch(/\bSTOP\s+and\s+wait/);
expect(prompt).toContain('You are a researcher.');
expect(prompt).toContain('Be thorough.');
expect(prompt).toContain('Gather data from the project.');
expect(prompt).toContain('Analyze findings.');
});
it('buildPrompt with execute+plan sanitizes output from buildExecutorPrompt', async () => {
await writeFile(
join(agentsDir, 'gsd-executor.md'),
makeAgentDef('gsd-executor', 'Read, Write, Edit, Bash', 'You are an executor.\nSTOP and wait for user.\nExecute thoroughly.'),
);
const factory = makeFactory();
const plan = makePromptParsedPlan({ objective: 'Build the auth system' });
const contextFiles: ContextFiles = { state: '# State' };
const prompt = await factory.buildPrompt(PhaseType.Execute, plan, contextFiles);
expect(prompt).toContain('Build the auth system');
expect(prompt).not.toMatch(/\bSTOP\s+and\s+wait/);
expect(prompt).toContain('You are an executor.');
});
});
});
describe('PHASE_WORKFLOW_MAP', () => {
it('maps all phase types to workflow filenames', () => {
for (const phase of Object.values(PhaseType)) {
expect(PHASE_WORKFLOW_MAP[phase]).toBeDefined();
expect(PHASE_WORKFLOW_MAP[phase]).toMatch(/\.md$/);
}
});
it('execute phase maps to execute-plan.md (not execute-phase.md)', () => {
expect(PHASE_WORKFLOW_MAP[PhaseType.Execute]).toBe('execute-plan.md');
});
});

View File

@@ -7,19 +7,17 @@ import { acquireStateLock, releaseStateLock } from './state-mutation.js';
*
* Port of replaceInCurrentMilestone from core.cjs lines 1013-1022.
*
* Semantics (byte-for-byte CJS parity):
* Semantics:
* • No `</details>` in the content → plain `content.replace(pattern, replacement)`.
* • Otherwise → split at the last `</details>` and replace only in the
* content AFTER it.
*
* INTENTIONALLY DOES NOT fall back to "search the last <details> block when
* the after-slice didn't match." That fallback existed in an earlier SDK
* port and would silently corrupt shipped-milestone content when the current
* milestone is itself wrapped in `<details open>...</details>` and there's
* nothing after the close tag. CJS callers handle the "milestone inside
* <details>" case by passing the unscoped `content.replace(...)` directly
* (see phase.cjs:1080 for plan-count update). Keep this function in
* lockstep with core.cjs — deviations are how bug-2005 slipped in.
* • If the after-slice produces no replacement (pattern not found there),
* fall back to replacing inside the last `<details>...</details>` block.
* This handles the case where the active milestone is itself wrapped in
* a `<details>` block (e.g. collapsed by the user or during a milestone
* transition). Earlier shipped-milestone blocks are left untouched because
* only the last `<details>` block is targeted by the fallback.
* (Fixes #2641.)
*/
export function replaceInCurrentMilestone(
content: string,
@@ -33,7 +31,23 @@ export function replaceInCurrentMilestone(
const offset = lastDetailsClose + '</details>'.length;
const before = content.slice(0, offset);
const after = content.slice(offset);
return before + after.replace(pattern, replacement);
const afterReplaced = after.replace(pattern, replacement);
if (afterReplaced !== after) {
// Pattern matched in the after-slice (normal case: active milestone is
// outside/after the last </details>).
return before + afterReplaced;
}
// Pattern did not match after the last </details>. Fall back to replacing
// inside the last <details> block (active milestone is wrapped in <details>).
const lastDetailsOpen = content.lastIndexOf('<details>');
if (lastDetailsOpen === -1 || lastDetailsOpen >= lastDetailsClose) {
// Malformed or no proper block — return unchanged.
return content;
}
const blockBefore = content.slice(0, lastDetailsOpen);
const blockInner = content.slice(lastDetailsOpen, offset);
const blockAfter = content.slice(offset);
return blockBefore + blockInner.replace(pattern, replacement) + blockAfter;
}
/**

View File

@@ -347,9 +347,18 @@ export const phasePlanIndex: QueryHandler = async (args, projectDir, workstream)
const phaseFiles = await readdir(phaseDir);
const planFiles = phaseFiles.filter(isCanonicalPlanFile).sort();
const summaryFiles = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
// #2893 parity — same diagnostic format as find-phase / phases-list. Use the
// centralised helper so the message shape never drifts between read sites.
const planNamingWarning = describeNonCanonicalPlans(phaseFiles, planFiles);
// #3430 — identify non-canonical plan-shaped files that were skipped so
// callers get an explicit warning rather than a silent plan_count: 0.
// Uses a concise format ("Ignored noncanonical plan files: <name,...>")
// for the warnings[] array; find-phase / phases-list use the verbose
// describeNonCanonicalPlans format which includes remediation guidance.
const matchedPlanSet = new Set(planFiles);
const nonCanonicalPlanFiles = phaseFiles.filter(
f => looksLikePlanFile(f) && !matchedPlanSet.has(f),
);
const planNamingWarning = nonCanonicalPlanFiles.length > 0
? `Ignored noncanonical plan files: ${nonCanonicalPlanFiles.join(', ')}`
: null;
// Build set of plan IDs with summaries — match the planId derivation logic
const completedPlanIds = new Set(
@@ -589,6 +598,12 @@ export const phasePlanIndex: QueryHandler = async (args, projectDir, workstream)
waves[waveKey].push(raw.id);
}
// #3430 — non-canonical plan filename warning flows through the same
// `warnings` array as other diagnostics (unresolved deps, wave mismatches).
if (planNamingWarning) {
warnings.push(planNamingWarning);
}
const result: Record<string, unknown> = {
phase: normalized,
plans,
@@ -596,12 +611,6 @@ export const phasePlanIndex: QueryHandler = async (args, projectDir, workstream)
incomplete,
has_checkpoints: hasCheckpoints,
};
// #2893 — non-canonical plan filename warning is a singular `warning` field;
// see describeNonCanonicalPlans above. Other diagnostics (unresolved deps,
// wave-declaration mismatches) flow through the existing `warnings` array.
if (planNamingWarning) {
result['warning'] = planNamingWarning;
}
if (warnings.length > 0) {
result['warnings'] = warnings;
}

View File

@@ -1,49 +0,0 @@
/**
* Regression tests for bug #1962
*
* normalizePhaseName must preserve the original case of letter suffixes.
* Uppercasing "16c" to "16C" causes directory/roadmap mismatches on
* case-sensitive filesystems — init progress can't match the directory
* back to the roadmap phase entry.
*/
'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const { normalizePhaseName } = require('../get-shit-done/bin/lib/core.cjs');
describe('bug #1962: normalizePhaseName preserves letter suffix case', () => {
test('lowercase suffix preserved: 16c → 16c', () => {
assert.equal(normalizePhaseName('16c'), '16c');
});
test('uppercase suffix preserved: 16C → 16C', () => {
assert.equal(normalizePhaseName('16C'), '16C');
});
test('single digit padded with lowercase suffix: 1a → 01a', () => {
assert.equal(normalizePhaseName('1a'), '01a');
});
test('single digit padded with uppercase suffix: 1A → 01A', () => {
assert.equal(normalizePhaseName('1A'), '01A');
});
test('no suffix unchanged: 16 → 16', () => {
assert.equal(normalizePhaseName('16'), '16');
});
test('decimal suffix preserved: 16.1 → 16.1', () => {
assert.equal(normalizePhaseName('16.1'), '16.1');
});
test('letter + decimal preserved: 16c.2 → 16c.2', () => {
assert.equal(normalizePhaseName('16c.2'), '16c.2');
});
test('project code prefix stripped, suffix case preserved: CK-01a → 01a', () => {
assert.equal(normalizePhaseName('CK-01a'), '01a');
});
});

View File

@@ -1,137 +0,0 @@
/**
* Regression tests for bug #1998
*
* phase complete must update the top-level overview bullet checkbox
* (- [ ] Phase N: → - [x] Phase N:) in addition to the Progress table row.
*
* Root cause: the checkbox update used replaceInCurrentMilestone() which
* scopes to content after </details>, missing the current milestone's
* overview bullets that appear before any <details> blocks.
*/
'use strict';
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { execFileSync } = require('node:child_process');
const gsdTools = path.resolve(__dirname, '..', 'get-shit-done', 'bin', 'gsd-tools.cjs');
describe('bug #1998: phase complete updates overview checkbox', () => {
let tmpDir;
let planningDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1998-'));
planningDir = path.join(tmpDir, '.planning');
fs.mkdirSync(planningDir, { recursive: true });
// Minimal config
fs.writeFileSync(
path.join(planningDir, 'config.json'),
JSON.stringify({ project_code: 'TEST' })
);
// Minimal STATE.md
fs.writeFileSync(
path.join(planningDir, 'STATE.md'),
'---\ncurrent_phase: 1\nstatus: executing\n---\n# State\n'
);
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('checkbox updated when no archived milestones exist', () => {
const phasesDir = path.join(planningDir, 'phases', '01-foundation');
fs.mkdirSync(phasesDir, { recursive: true });
fs.writeFileSync(
path.join(phasesDir, '01-1-SUMMARY.md'),
'---\nstatus: complete\n---\n# Summary\nDone.'
);
fs.writeFileSync(
path.join(phasesDir, '01-1-PLAN.md'),
'---\nphase: 1\nplan: 1\n---\n# Plan 1\n'
);
const roadmapPath = path.join(planningDir, 'ROADMAP.md');
fs.writeFileSync(roadmapPath, [
'# Roadmap',
'',
'## Phases',
'',
'- [ ] **Phase 1: Foundation** - core setup',
'- [ ] **Phase 2: Features** - add features',
'',
'## Progress',
'',
'| Phase | Plans | Status | Completed |',
'|-------|-------|--------|-----------|',
'| 1. Foundation | 0/1 | Pending | - |',
'| 2. Features | 0/1 | Pending | - |',
].join('\n'));
try {
execFileSync('node', [gsdTools, 'phase', 'complete', '1'], { cwd: tmpDir, timeout: 10000 });
} catch {
// Command may exit non-zero if STATE.md update fails, but ROADMAP.md update happens first
}
const result = fs.readFileSync(roadmapPath, 'utf-8');
assert.match(result, /- \[x\] \*\*Phase 1: Foundation\*\*/, 'overview checkbox should be checked');
assert.match(result, /- \[ \] \*\*Phase 2: Features\*\*/, 'phase 2 checkbox should remain unchecked');
});
test('checkbox updated when archived milestones exist in <details>', () => {
const phasesDir = path.join(planningDir, 'phases', '01-setup');
fs.mkdirSync(phasesDir, { recursive: true });
fs.writeFileSync(
path.join(phasesDir, '01-1-SUMMARY.md'),
'---\nstatus: complete\n---\n# Summary\nDone.'
);
fs.writeFileSync(
path.join(phasesDir, '01-1-PLAN.md'),
'---\nphase: 1\nplan: 1\n---\n# Plan 1\n'
);
const roadmapPath = path.join(planningDir, 'ROADMAP.md');
fs.writeFileSync(roadmapPath, [
'# Roadmap v2.0',
'',
'## Phases',
'',
'- [ ] **Phase 1: Setup** - initial setup',
'- [ ] **Phase 2: Build** - build features',
'',
'## Progress',
'',
'| Phase | Plans | Status | Completed |',
'|-------|-------|--------|-----------|',
'| 1. Setup | 0/1 | Pending | - |',
'| 2. Build | 0/1 | Pending | - |',
'',
'<details>',
'<summary>v1.0 (Archived)</summary>',
'',
'## v1.0 Phases',
'- [x] **Phase 1: Init** - initialization',
'- [x] **Phase 2: Deploy** - deployment',
'',
'</details>',
].join('\n'));
try {
execFileSync('node', [gsdTools, 'phase', 'complete', '1'], { cwd: tmpDir, timeout: 10000 });
} catch {
// May exit non-zero
}
const result = fs.readFileSync(roadmapPath, 'utf-8');
assert.match(result, /- \[x\] \*\*Phase 1: Setup\*\*/, 'current milestone checkbox should be checked');
assert.match(result, /- \[ \] \*\*Phase 2: Build\*\*/, 'phase 2 checkbox should remain unchecked');
});
});

View File

@@ -1,180 +0,0 @@
/**
* Regression tests for bug #2005
*
* When the in-progress milestone section is wrapped in a <details> block
* (the standard /gsd-new-project layout), phase complete silently skips:
* 1. The plan count update (**Plans:** N/M → X/M plans complete)
* 2. Mis-reports is_last_phase and next_phase
*
* Root cause: replaceInCurrentMilestone() uses the last </details> as the
* boundary, so when the current milestone is itself inside <details>, the
* replacement target is in the empty space AFTER the current milestone's
* closing </details>, and the regex never matches anything inside the block.
*/
'use strict';
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { execFileSync } = require('node:child_process');
const gsdTools = path.resolve(__dirname, '..', 'get-shit-done', 'bin', 'gsd-tools.cjs');
describe('bug #2005: phase complete updates plan count when milestone is inside <details>', () => {
let tmpDir;
let planningDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2005-'));
planningDir = path.join(tmpDir, '.planning');
fs.mkdirSync(planningDir, { recursive: true });
fs.writeFileSync(
path.join(planningDir, 'config.json'),
JSON.stringify({ project_code: 'TEST' })
);
fs.writeFileSync(
path.join(planningDir, 'STATE.md'),
'---\ncurrent_phase: 1\nstatus: executing\nmilestone: v2.0\n---\n# State\n'
);
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('plan count is updated when current milestone is wrapped in <details>', () => {
// This is the standard /gsd-new-project layout: every milestone in <details>
const phasesDir = path.join(planningDir, 'phases', '01-setup');
fs.mkdirSync(phasesDir, { recursive: true });
fs.writeFileSync(
path.join(phasesDir, '01-1-SUMMARY.md'),
'---\nstatus: complete\n---\n# Summary\nDone.\n'
);
fs.writeFileSync(
path.join(phasesDir, '01-1-PLAN.md'),
'---\nphase: 1\nplan: 1\n---\n# Plan 1\n'
);
const roadmapPath = path.join(planningDir, 'ROADMAP.md');
// Current milestone (v2.0) is wrapped in <details>
fs.writeFileSync(roadmapPath, [
'# Roadmap',
'',
'<details>',
'<summary>v1.0 (shipped)</summary>',
'',
'## v1.0 Phases',
'- [x] **Phase 0: Bootstrap** - shipped',
'',
'</details>',
'',
'<details open>',
'<summary>v2.0 (in progress)</summary>',
'',
'## v2.0 Phases',
'',
'- [ ] **Phase 1: Setup** - initial setup',
'- [ ] **Phase 2: Build** - build features',
'',
'## Progress',
'',
'| Phase | Plans | Status | Completed |',
'|-------|-------|--------|-----------|',
'| 1. Setup | 0/1 | Pending | - |',
'| 2. Build | 0/1 | Pending | - |',
'',
'### Phase 1: Setup',
'',
'**Plans:** 0/1 plans complete',
'',
'### Phase 2: Build',
'',
'**Plans:** 0/1 plans complete',
'',
'</details>',
].join('\n'));
try {
execFileSync('node', [gsdTools, 'phase', 'complete', '1'], { cwd: tmpDir, timeout: 10000 });
} catch {
// May exit non-zero if STATE.md update fails, but ROADMAP.md update is the target
}
const result = fs.readFileSync(roadmapPath, 'utf-8');
// Plan count must be updated inside the <details> block
assert.match(
result,
/\*\*Plans:\*\*\s*1\/1 plans complete/,
'plan count in Phase 1 section must be updated to 1/1 plans complete'
);
// Phase 1 checkbox must be checked
assert.match(
result,
/- \[x\] \*\*Phase 1: Setup\*\*/,
'Phase 1 checkbox must be checked after completion'
);
// Phase 2 must be untouched
assert.match(
result,
/- \[ \] \*\*Phase 2: Build\*\*/,
'Phase 2 checkbox must remain unchecked'
);
});
test('phase complete with all milestones in <details> does not corrupt phase 2 plan count', () => {
const phasesDir = path.join(planningDir, 'phases', '01-setup');
fs.mkdirSync(phasesDir, { recursive: true });
fs.writeFileSync(
path.join(phasesDir, '01-1-SUMMARY.md'),
'---\nstatus: complete\n---\n# Summary\nDone.\n'
);
fs.writeFileSync(
path.join(phasesDir, '01-1-PLAN.md'),
'---\nphase: 1\nplan: 1\n---\n# Plan 1\n'
);
const roadmapPath = path.join(planningDir, 'ROADMAP.md');
fs.writeFileSync(roadmapPath, [
'# Roadmap',
'',
'<details open>',
'<summary>v2.0 (in progress)</summary>',
'',
'## v2.0 Phases',
'',
'- [ ] **Phase 1: Setup** - initial setup',
'- [ ] **Phase 2: Build** - build features',
'',
'### Phase 1: Setup',
'',
'**Plans:** 0/1 plans complete',
'',
'### Phase 2: Build',
'',
'**Plans:** 0/2 plans complete',
'',
'</details>',
].join('\n'));
try {
execFileSync('node', [gsdTools, 'phase', 'complete', '1'], { cwd: tmpDir, timeout: 10000 });
} catch {}
const result = fs.readFileSync(roadmapPath, 'utf-8');
// Phase 2's plan count must NOT be touched
assert.match(
result,
/Phase 2: Build[\s\S]*?\*\*Plans:\*\*\s*0\/2 plans complete/,
'Phase 2 plan count must remain 0/2 (untouched)'
);
});
});

View File

@@ -1,271 +0,0 @@
/**
* Regression tests for bug #2526
*
* phase complete must warn about REQ-IDs that appear in the REQUIREMENTS.md
* body but are missing from the Traceability table.
*
* Root cause: cmdPhaseComplete() only flips status for REQ-IDs already in
* the Traceability table (from the roadmap **Requirements:** line). REQ-IDs
* added to the REQUIREMENTS.md body after roadmap creation are never
* discovered or reflected in the table.
*
* Fix (Option A — warning only): scan the REQUIREMENTS.md body for all
* REQ-IDs, check which are absent from the Traceability table, and emit
* a warning listing the missing IDs.
*/
'use strict';
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { execFileSync } = require('node:child_process');
const gsdTools = path.resolve(__dirname, '..', 'get-shit-done', 'bin', 'gsd-tools.cjs');
describe('bug #2526: phase complete warns about unregistered REQ-IDs', () => {
let tmpDir;
let planningDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2526-'));
planningDir = path.join(tmpDir, '.planning');
fs.mkdirSync(planningDir, { recursive: true });
// Minimal config
fs.writeFileSync(
path.join(planningDir, 'config.json'),
JSON.stringify({ project_code: '' })
);
// Minimal STATE.md
fs.writeFileSync(
path.join(planningDir, 'STATE.md'),
'---\ncurrent_phase: 1\nstatus: executing\n---\n# State\n'
);
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('emits warning for REQ-IDs in body but missing from Traceability table', () => {
// Set up phase directory with a plan and summary
const phasesDir = path.join(planningDir, 'phases', '01-foundation');
fs.mkdirSync(phasesDir, { recursive: true });
fs.writeFileSync(
path.join(phasesDir, '01-1-PLAN.md'),
'---\nphase: 1\nplan: 1\n---\n# Plan 1\n'
);
fs.writeFileSync(
path.join(phasesDir, '01-1-SUMMARY.md'),
'---\nstatus: complete\n---\n# Summary\nDone.'
);
// ROADMAP.md — phase 1 lists only REQ-001 in its Requirements line
const roadmapPath = path.join(planningDir, 'ROADMAP.md');
fs.writeFileSync(roadmapPath, [
'# Roadmap',
'',
'### Phase 1: Foundation',
'',
'**Goal:** Build core',
'**Requirements:** REQ-001',
'**Plans:** 1 plans',
'',
'Plans:',
'- [x] 01-1-PLAN.md',
'',
'| Phase | Plans | Status | Completed |',
'|-------|-------|--------|-----------|',
'| 1. Foundation | 0/1 | Pending | - |',
].join('\n'));
// REQUIREMENTS.md — body has REQ-001 (in table) and REQ-002, REQ-003 (missing from table)
const reqPath = path.join(planningDir, 'REQUIREMENTS.md');
fs.writeFileSync(reqPath, [
'# Requirements',
'',
'## Functional Requirements',
'',
'- [x] **REQ-001**: Core data model',
'- [ ] **REQ-002**: User authentication',
'- [ ] **REQ-003**: API endpoints',
'',
'## Traceability',
'',
'| REQ-ID | Phase | Status |',
'|--------|-------|--------|',
'| REQ-001 | 1 | Pending |',
].join('\n'));
let stdout = '';
let stderr = '';
try {
const result = execFileSync('node', [gsdTools, 'phase', 'complete', '1'], {
cwd: tmpDir,
timeout: 10000,
encoding: 'utf-8',
});
stdout = result;
} catch (err) {
stdout = err.stdout || '';
stderr = err.stderr || '';
throw err;
}
const combined = stdout + stderr;
assert.match(
combined,
/REQ-002/,
'output should mention REQ-002 as missing from Traceability table'
);
assert.match(
combined,
/REQ-003/,
'output should mention REQ-003 as missing from Traceability table'
);
});
test('no warning when all body REQ-IDs are present in Traceability table', () => {
// Set up phase directory
const phasesDir = path.join(planningDir, 'phases', '01-foundation');
fs.mkdirSync(phasesDir, { recursive: true });
fs.writeFileSync(
path.join(phasesDir, '01-1-PLAN.md'),
'---\nphase: 1\nplan: 1\n---\n# Plan 1\n'
);
fs.writeFileSync(
path.join(phasesDir, '01-1-SUMMARY.md'),
'---\nstatus: complete\n---\n# Summary\nDone.'
);
const roadmapPath = path.join(planningDir, 'ROADMAP.md');
fs.writeFileSync(roadmapPath, [
'# Roadmap',
'',
'### Phase 1: Foundation',
'',
'**Goal:** Build core',
'**Requirements:** REQ-001, REQ-002',
'**Plans:** 1 plans',
'',
'Plans:',
'- [x] 01-1-PLAN.md',
'',
'| Phase | Plans | Status | Completed |',
'|-------|-------|--------|-----------|',
'| 1. Foundation | 0/1 | Pending | - |',
].join('\n'));
// All body REQ-IDs are present in the Traceability table
const reqPath = path.join(planningDir, 'REQUIREMENTS.md');
fs.writeFileSync(reqPath, [
'# Requirements',
'',
'## Functional Requirements',
'',
'- [x] **REQ-001**: Core data model',
'- [x] **REQ-002**: User authentication',
'',
'## Traceability',
'',
'| REQ-ID | Phase | Status |',
'|--------|-------|--------|',
'| REQ-001 | 1 | Pending |',
'| REQ-002 | 1 | Pending |',
].join('\n'));
let stdout = '';
let stderr = '';
try {
const result = execFileSync('node', [gsdTools, 'phase', 'complete', '1'], {
cwd: tmpDir,
timeout: 10000,
encoding: 'utf-8',
});
stdout = result;
} catch (err) {
stdout = err.stdout || '';
stderr = err.stderr || '';
throw err;
}
const combined = stdout + stderr;
assert.doesNotMatch(
combined,
/unregistered|missing.*traceability|not in.*traceability/i,
'no warning should appear when all REQ-IDs are in the table'
);
});
test('warning includes all missing REQ-IDs, not just the first', () => {
const phasesDir = path.join(planningDir, 'phases', '01-foundation');
fs.mkdirSync(phasesDir, { recursive: true });
fs.writeFileSync(
path.join(phasesDir, '01-1-PLAN.md'),
'---\nphase: 1\nplan: 1\n---\n# Plan 1\n'
);
fs.writeFileSync(
path.join(phasesDir, '01-1-SUMMARY.md'),
'---\nstatus: complete\n---\n# Summary\nDone.'
);
const roadmapPath = path.join(planningDir, 'ROADMAP.md');
fs.writeFileSync(roadmapPath, [
'# Roadmap',
'',
'### Phase 1: Foundation',
'',
'**Goal:** Build core',
'**Requirements:** REQ-001',
'**Plans:** 1 plans',
'',
'Plans:',
'- [x] 01-1-PLAN.md',
'',
'| Phase | Plans | Status | Completed |',
'|-------|-------|--------|-----------|',
'| 1. Foundation | 0/1 | Pending | - |',
].join('\n'));
// Body has 4 REQ-IDs; table only has 1
const reqPath = path.join(planningDir, 'REQUIREMENTS.md');
fs.writeFileSync(reqPath, [
'# Requirements',
'',
'- [x] **REQ-001**: Core data model',
'- [ ] **REQ-002**: User auth',
'- [ ] **REQ-003**: API',
'- [ ] **REQ-004**: Reports',
'',
'## Traceability',
'',
'| REQ-ID | Phase | Status |',
'|--------|-------|--------|',
'| REQ-001 | 1 | Pending |',
].join('\n'));
let stdout = '';
let stderr = '';
try {
const result = execFileSync('node', [gsdTools, 'phase', 'complete', '1'], {
cwd: tmpDir,
timeout: 10000,
encoding: 'utf-8',
});
stdout = result;
} catch (err) {
stdout = err.stdout || '';
stderr = err.stderr || '';
throw err;
}
const combined = stdout + stderr;
assert.match(combined, /REQ-002/, 'should warn about REQ-002');
assert.match(combined, /REQ-003/, 'should warn about REQ-003');
assert.match(combined, /REQ-004/, 'should warn about REQ-004');
});
});

View File

@@ -1,198 +0,0 @@
'use strict';
/**
* Regression test for #3287 — phase-dir prefix parity across creation paths.
*
* Projects with `project_code` set in `.planning/config.json` must get the
* same `<CODE>-<NN>-<slug>` directory shape from ALL phase-creation paths,
* not just from `phase.add` / `phase.insert`.
*
* Three tests:
* A — sanity: `phase.add` emits the prefixed dir (already works).
* B — init phase-op exposes `expected_phase_dir` with the prefix when
* the directory does not yet exist (first-touch path for /gsd-discuss-phase).
* C — init plan-phase exposes `expected_phase_dir` with the prefix when
* the directory does not yet exist (first-touch path for /gsd-plan-phase).
*
* Tests B and C are RED until the fix lands.
*/
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');
// ─── shared fixture ──────────────────────────────────────────────────────────
function makeXRProject(tmpDir) {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ project_code: 'XR' }),
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'# Roadmap v1.0',
'',
'### Phase 1: Foundation',
'**Goal:** Setup project',
'**Plans:** 0 plans',
'',
'---',
'',
].join('\n'),
);
}
// ─── Test A — sanity: phase.add honours project_code ─────────────────────────
describe('bug-3287 — phase.add emits project_code prefix (sanity)', () => {
let tmpDir;
beforeEach(() => { tmpDir = createTempProject(); });
afterEach(() => { cleanup(tmpDir); });
test('phase.add creates XR-02-<slug> when project_code is XR', () => {
makeXRProject(tmpDir);
const result = runGsdTools('phase add auth service', tmpDir, { HOME: tmpDir });
assert.ok(result.success, `phase.add failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_number, 2, 'phase number should be 2');
const phasesDir = path.join(tmpDir, '.planning', 'phases');
const dirs = fs.readdirSync(phasesDir);
const prefixedDirs = dirs.filter(d => d.startsWith('XR-'));
assert.ok(
prefixedDirs.length > 0,
`Expected at least one XR- prefixed dir, got: ${JSON.stringify(dirs)}`,
);
assert.ok(
dirs.some(d => d === 'XR-02-auth-service'),
`Expected XR-02-auth-service, got: ${JSON.stringify(dirs)}`,
);
});
});
// ─── Test B — init phase-op exposes expected_phase_dir with prefix ────────────
describe('bug-3287 — init phase-op exposes expected_phase_dir with project_code prefix', () => {
let tmpDir;
beforeEach(() => { tmpDir = createTempProject(); });
afterEach(() => { cleanup(tmpDir); });
test('returns expected_phase_dir with XR- prefix when phase directory does not exist', () => {
makeXRProject(tmpDir);
// Phase 1 is in the roadmap but has no directory yet — the first-touch path
const result = runGsdTools('init phase-op 1', tmpDir, { HOME: tmpDir });
assert.ok(result.success, `init phase-op failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_found, true, 'phase should be found in roadmap');
assert.strictEqual(output.phase_dir, null, 'phase_dir should be null (no dir yet)');
// The fix: expected_phase_dir must carry the project_code prefix
assert.ok(
typeof output.expected_phase_dir === 'string',
`expected_phase_dir should be a string, got: ${JSON.stringify(output.expected_phase_dir)}`,
);
assert.ok(
output.expected_phase_dir.includes('XR-'),
`expected_phase_dir should contain XR- prefix, got: "${output.expected_phase_dir}"`,
);
assert.ok(
output.expected_phase_dir.includes('foundation'),
`expected_phase_dir should contain the phase slug, got: "${output.expected_phase_dir}"`,
);
});
test('expected_phase_dir is null when no project_code is set', () => {
// No project_code — expected_phase_dir should still be present but without prefix
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({}),
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n',
);
const result = runGsdTools('init phase-op 1', tmpDir, { HOME: tmpDir });
assert.ok(result.success, `init phase-op failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_dir, null);
assert.ok(
typeof output.expected_phase_dir === 'string',
`expected_phase_dir should be a string even without project_code, got: ${JSON.stringify(output.expected_phase_dir)}`,
);
// Without project_code, should have no prefix — just NN-slug
assert.ok(
!output.expected_phase_dir.match(/^[A-Z][A-Z0-9]*-/),
`expected_phase_dir should have NO prefix without project_code, got: "${output.expected_phase_dir}"`,
);
});
});
// ─── Test C — init plan-phase exposes expected_phase_dir with prefix ──────────
describe('bug-3287 — init plan-phase exposes expected_phase_dir with project_code prefix', () => {
let tmpDir;
beforeEach(() => { tmpDir = createTempProject(); });
afterEach(() => { cleanup(tmpDir); });
test('returns expected_phase_dir with XR- prefix when phase directory does not exist', () => {
makeXRProject(tmpDir);
// Phase 1 is in the roadmap but has no directory yet — the first-touch path
const result = runGsdTools('init plan-phase 1', tmpDir, { HOME: tmpDir });
assert.ok(result.success, `init plan-phase failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_found, true, 'phase should be found in roadmap');
assert.strictEqual(output.phase_dir, null, 'phase_dir should be null (no dir yet)');
// The fix: expected_phase_dir must carry the project_code prefix
assert.ok(
typeof output.expected_phase_dir === 'string',
`expected_phase_dir should be a string, got: ${JSON.stringify(output.expected_phase_dir)}`,
);
assert.ok(
output.expected_phase_dir.includes('XR-'),
`expected_phase_dir should contain XR- prefix, got: "${output.expected_phase_dir}"`,
);
assert.ok(
output.expected_phase_dir.includes('foundation'),
`expected_phase_dir should contain the phase slug, got: "${output.expected_phase_dir}"`,
);
});
test('expected_phase_dir omits prefix when project_code is not set', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({}),
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n',
);
const result = runGsdTools('init plan-phase 1', tmpDir, { HOME: tmpDir });
assert.ok(result.success, `init plan-phase failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_dir, null);
assert.ok(
typeof output.expected_phase_dir === 'string',
`expected_phase_dir should be a string, got: ${JSON.stringify(output.expected_phase_dir)}`,
);
assert.ok(
!output.expected_phase_dir.match(/^[A-Z][A-Z0-9]*-/),
`expected_phase_dir should have NO prefix without project_code, got: "${output.expected_phase_dir}"`,
);
});
});

View File

@@ -1,171 +0,0 @@
'use strict';
/**
* Regression test for #3298 — phase-dir prefix drift in /gsd-plan-milestone-gaps,
* /gsd-import, and /gsd-capture --backlog workflows (PRED.k015 sibling audit).
*
* Projects with `project_code` set in `.planning/config.json` must have
* consistent `<CODE>-<NN>-<slug>` directory naming across ALL phase-creation
* paths. PR #3292 (#3287) fixed `/gsd-discuss-phase` and `/gsd-plan-phase`.
*
* Missed sites (this PR):
* 1. `plan-milestone-gaps.md` step 8 — raw `{NN}-{name}` mkdir pattern.
* 2. `import.md` plan_convert step — raw `{NN}-{slug}` mkdir pattern.
* 3. `add-backlog.md` step 4 — raw `${NEXT}-${SLUG}` mkdir pattern
* (backlog uses 999.x numbering; still subject to project_code prefix).
*
* The fix: all three files must resolve the directory name via `init.phase-op`
* (which exposes `expected_phase_dir` with the project_code prefix) or use
* a `project_code`-aware helper before calling mkdir.
*
* Tests are structural (parse-level) — no source-grep on raw strings.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const PMG_WF = path.join(
__dirname, '..', 'get-shit-done', 'workflows', 'plan-milestone-gaps.md',
);
const IMPORT_WF = path.join(
__dirname, '..', 'get-shit-done', 'workflows', 'import.md',
);
const BACKLOG_WF = path.join(
__dirname, '..', 'get-shit-done', 'workflows', 'add-backlog.md',
);
// ─── helpers ─────────────────────────────────────────────────────────────────
function readWorkflow(filePath) {
try {
return fs.readFileSync(filePath, 'utf8');
} catch (err) {
throw new Error(`Cannot read workflow file ${filePath}: ${err.message}`);
}
}
/**
* Returns true when the content contains a bare `mkdir -p ".planning/phases/{NN}-{name}"`
* or `mkdir -p ".planning/phases/{NN}-{slug}"` pattern that does NOT include
* a `project_code`/`expected_phase_dir` variable — i.e., the unfixed drift pattern.
*
* We look for `mkdir` lines that reference .planning/phases/ where the directory
* component starts with `{` (template literal, no variable substitution).
*/
function containsBareTemplateMkdir(content) {
// Match lines like: mkdir -p ".planning/phases/{NN}-{name}"
// or: mkdir -p ".planning/phases/{NN}-{slug}/"
// These are the drift patterns — they don't use expected_phase_dir.
return /mkdir[^`\n]*\.planning\/phases\/\{[A-Z0-9]+\}-\{/.test(content);
}
/**
* Returns true when the content contains a bare shell-variable mkdir pattern like:
* mkdir -p ".planning/phases/${NEXT}-${SLUG}"
* without a project_code prefix variable before `${NEXT}` (or similar).
*
* The drift pattern is: directory path starts with `${NEXT}` (or `${NN}`) directly,
* with no preceding `${PREFIX}` or `${CODE}` variable that would carry project_code.
*/
function containsBareShellVarMkdir(content) {
// Match mkdir lines where the phases/ directory component starts with a bare
// shell variable like ${NEXT} or ${NN} — no prefix variable before it.
// Positive match: mkdir .../phases/${NEXT}- or .../phases/${NN}-
// We exclude lines that have a variable BEFORE ${NEXT}/${NN} (i.e., a prefix var).
return /mkdir[^`\n]*\.planning\/phases\/"\$\{(?:NEXT|NN|PHASE)[^}]*\}-/.test(content)
|| /mkdir[^`\n]*\.planning\/phases\/\$\{(?:NEXT|NN|PHASE)[^}]*\}-/.test(content);
}
// ─── plan-milestone-gaps.md ───────────────────────────────────────────────────
describe('bug-3298 — plan-milestone-gaps.md must not construct bare {NN}-{name} phase dirs', () => {
test('workflow file exists', () => {
assert.ok(
fs.existsSync(PMG_WF),
`plan-milestone-gaps.md must exist at ${PMG_WF}`,
);
});
test('step 8 must not use bare {NN}-{name} mkdir pattern', () => {
const content = readWorkflow(PMG_WF);
assert.ok(
!containsBareTemplateMkdir(content),
'plan-milestone-gaps.md must not contain bare mkdir .planning/phases/{NN}-{name} pattern — use phase.add or expected_phase_dir',
);
});
test('step 8 must use expected_phase_dir or phase.add for directory creation', () => {
const content = readWorkflow(PMG_WF);
const usesExpectedPhaseDir = content.includes('expected_phase_dir');
const usesPhaseAdd = content.includes('phase.add');
assert.ok(
usesExpectedPhaseDir || usesPhaseAdd,
'plan-milestone-gaps.md must use expected_phase_dir (from init.phase-op) or phase.add to create phase directories with project_code prefix',
);
});
});
// ─── import.md ───────────────────────────────────────────────────────────────
describe('bug-3298 — import.md must not construct bare {NN}-{slug} phase dirs', () => {
test('workflow file exists', () => {
assert.ok(
fs.existsSync(IMPORT_WF),
`import.md must exist at ${IMPORT_WF}`,
);
});
test('plan_convert step must not use bare {NN}-{slug} mkdir pattern', () => {
const content = readWorkflow(IMPORT_WF);
assert.ok(
!containsBareTemplateMkdir(content),
'import.md must not contain bare mkdir .planning/phases/{NN}-{slug} pattern — use expected_phase_dir from init.phase-op',
);
});
test('plan_convert step must use expected_phase_dir for directory creation', () => {
const content = readWorkflow(IMPORT_WF);
assert.ok(
content.includes('expected_phase_dir'),
'import.md must use expected_phase_dir (from init.phase-op) to create phase directory with project_code prefix',
);
});
test('plan_convert step must call init.phase-op to resolve the prefixed dir', () => {
const content = readWorkflow(IMPORT_WF);
assert.ok(
content.includes('init.phase-op') || content.includes('init phase-op'),
'import.md must call gsd-sdk query init.phase-op to get expected_phase_dir with project_code prefix',
);
});
});
// ─── add-backlog.md (sibling site found during k015 audit) ───────────────────
describe('bug-3298 — add-backlog.md must apply project_code prefix when creating 999.x dirs', () => {
test('workflow file exists', () => {
assert.ok(
fs.existsSync(BACKLOG_WF),
`add-backlog.md must exist at ${BACKLOG_WF}`,
);
});
test('step 4 must not use bare ${NEXT}-${SLUG} mkdir without project_code prefix', () => {
const content = readWorkflow(BACKLOG_WF);
assert.ok(
!containsBareShellVarMkdir(content),
'add-backlog.md must not create .planning/phases/${NEXT}-${SLUG} without a project_code prefix variable — apply ${PREFIX} (or equivalent) before ${NEXT}',
);
});
test('step 4 must reference project_code or a prefix variable before the phase number', () => {
const content = readWorkflow(BACKLOG_WF);
const hasProjectCodeRef = content.includes('project_code') || content.includes('PROJECT_CODE');
const hasPrefixVar = content.includes('${PREFIX}') || content.includes('${PHASE_PREFIX}') || content.includes('${CODE}');
assert.ok(
hasProjectCodeRef || hasPrefixVar,
'add-backlog.md must read project_code (or use a PREFIX variable) to apply the project_code prefix to the 999.x phase directory name',
);
});
});

View File

@@ -1,417 +0,0 @@
// allow-test-rule: state-md-is-the-runtime-contract — this test asserts the
// exact STATE.md fields written by phase.complete; STATE.md IS the product
// surface being verified, not source code. Same justification pattern as
// tests/bug-2698-crlf-install.test.cjs. Migration to typed-IR parser tracked
// in #2974.
/**
* Regression tests for bug #3517:
* `gsd-sdk query phase.complete N` returns state_updated: true but
* leaves STATE.md with stale fields.
*
* Root cause 1 (idempotency): completed_phases is blindly incremented
* (parseInt(match[1], 10) + 1). Running phase.complete N twice
* double-counts: 4 → 5 → 6 across two phase.complete 5 runs.
*
* Root cause 2 (field coverage): only 4 of ~10 stale STATE.md fields
* get refreshed. Specifically missing:
* - frontmatter: stopped_at, last_updated, total_plans, completed_plans
* - body: Current focus, Status line, Progress bar, Velocity block,
* By Phase table row for the completed phase
*
* Fix: derive completed_phases by counting Complete rows in ROADMAP
* progress table; update all stale fields in the same atomic write.
*/
'use strict';
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { execFileSync } = require('node:child_process');
const SDK_CLI = path.join(__dirname, '..', 'sdk', 'dist', 'cli.js');
// ─── Helpers ─────────────────────────────────────────────────────────────────
function runSdkQuery(args, cwd) {
try {
const result = execFileSync(process.execPath, [SDK_CLI, 'query', ...args], {
cwd,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
});
const parsed = JSON.parse(result.trim());
return { success: true, data: parsed };
} catch (err) {
const stderr = err.stderr?.toString().trim() || '';
const stdout = err.stdout?.toString().trim() || '';
try {
const parsed = JSON.parse(stdout);
return { success: true, data: parsed };
} catch { /* not JSON */ }
return { success: false, error: stderr || err.message };
}
}
function setupProject(tmpDir) {
const planningDir = path.join(tmpDir, '.planning');
const phasesDir = path.join(planningDir, 'phases');
fs.mkdirSync(planningDir, { recursive: true });
fs.mkdirSync(phasesDir, { recursive: true });
// config.json — required for planningPaths
fs.writeFileSync(
path.join(planningDir, 'config.json'),
JSON.stringify({ project_code: 'TEST' }),
);
// ROADMAP.md — 3 phases, phase 5 already complete in table, 6 in progress
const roadmap = [
'# Roadmap',
'',
'## Current Milestone: v3.0',
'',
'| Phase | Plans | Status | Completed |',
'|-------|-------|--------|-----------|',
'| 4. | 3/3 | Complete | 2026-04-01 |',
'| 5. | 7/7 | In Progress | |',
'| 6. | 0/5 | Not Started | |',
'',
'- [x] Phase 4: Foundation (completed 2026-04-01)',
'- [ ] Phase 5: Core API',
'- [ ] Phase 6: Integration',
'',
'### Phase 4: Foundation',
'',
'**Goal:** Foundation work',
'**Plans:** 3/3 plans complete',
'',
'### Phase 5: Core API',
'',
'**Goal:** Build core API layer',
'**Plans:** 7 plans',
'',
'Plans:',
'- [ ] 05-01 plan',
'- [ ] 05-02 plan',
'- [ ] 05-03 plan',
'- [ ] 05-04 plan',
'- [ ] 05-05 plan',
'- [ ] 05-06 plan',
'- [ ] 05-07 plan',
'',
'### Phase 6: Integration',
'',
'**Goal:** Integration work',
'**Plans:** 5 plans',
'',
'---',
'*Last updated: 2026-05-14*',
].join('\n');
fs.writeFileSync(path.join(planningDir, 'ROADMAP.md'), roadmap);
// STATE.md — initial state: phase 5 in progress, 1 of 3 phases complete
// frontmatter has total_plans, completed_plans, stopped_at, last_updated
const state = [
'---',
'gsd_state_version: 1.0',
'milestone: v3.0',
'milestone_name: Core Platform',
'status: executing',
'stopped_at: Completed 05-03-PLAN.md',
'last_updated: 2026-05-10T08:00:00.000Z',
'progress:',
' total_phases: 3',
' completed_phases: 1',
' total_plans: 15',
' completed_plans: 6',
' percent: 33',
'---',
'',
'# Project State',
'',
'## Current Position',
'',
'**Current focus:** Phase 5 — Core API',
'Phase: 5 of 3 (Core API) — EXECUTING',
'Plan: 7 of 7',
'Status: Executing Phase 5',
'Last activity: 2026-05-10',
'',
'## Progress',
'',
'Progress: [████████████░░░░] 40% (1/3 phases complete before Phase 5 closeout)',
'',
'## Performance Metrics',
'',
'**Velocity:**',
'',
'- Total plans completed: 6',
'- Average duration: 2h',
'- Total execution time: 14 hours',
'- Window: 2026-04-01 to 2026-05-10',
'',
'**By Phase:**',
'',
'| Phase | Plans | Total | Avg/Plan |',
'|-------|-------|-------|----------|',
'| 4 | 3 | - | - |',
'',
'## Session Continuity',
'',
'Last session: 2026-05-10T08:00:00.000Z',
'Stopped at: Completed 05-07-PLAN.md',
].join('\n');
fs.writeFileSync(path.join(planningDir, 'STATE.md'), state);
// Phase 5 directory with 7 plans and 7 summaries (all complete)
const phase5Dir = path.join(phasesDir, '05-core-api');
fs.mkdirSync(phase5Dir, { recursive: true });
for (let i = 1; i <= 7; i++) {
const padded = String(i).padStart(2, '0');
fs.writeFileSync(path.join(phase5Dir, `05-${padded}-PLAN.md`), `plan ${i}`, 'utf8');
fs.writeFileSync(path.join(phase5Dir, `05-${padded}-SUMMARY.md`), `summary ${i}`, 'utf8');
}
// Phase 4 directory (already complete)
const phase4Dir = path.join(phasesDir, '04-foundation');
fs.mkdirSync(phase4Dir, { recursive: true });
for (let i = 1; i <= 3; i++) {
const padded = String(i).padStart(2, '0');
fs.writeFileSync(path.join(phase4Dir, `04-${padded}-PLAN.md`), `plan ${i}`, 'utf8');
fs.writeFileSync(path.join(phase4Dir, `04-${padded}-SUMMARY.md`), `summary ${i}`, 'utf8');
}
// Phase 6 directory (not started — no plans or summaries yet)
const phase6Dir = path.join(phasesDir, '06-integration');
fs.mkdirSync(phase6Dir, { recursive: true });
return { planningDir, phase5Dir };
}
// ─── Tests ───────────────────────────────────────────────────────────────────
describe('bug #3517: phase.complete leaves STATE.md with stale fields', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3517-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('completed_phases is derived from ROADMAP, not blindly incremented (idempotency)', () => {
// Root cause 1: integer += 1 means phase.complete 5 twice yields 3.
// Fix: count Complete rows in ROADMAP progress table after the update.
setupProject(tmpDir);
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
// First call — should set completed_phases = 2 (phase 4 + phase 5 = 2 complete)
const r1 = runSdkQuery(['phase.complete', '5'], tmpDir);
assert.ok(r1.success, `first call failed: ${r1.error}`);
const stateAfter1 = fs.readFileSync(statePath, 'utf8');
const match1 = stateAfter1.match(/completed_phases:\s*(\d+)/);
assert.ok(match1, 'completed_phases not found in frontmatter after first call');
assert.equal(
Number(match1[1]),
2,
`After first call: completed_phases should be 2 (derived from ROADMAP: phases 4 and 5 complete), got ${match1[1]}`,
);
// Second call on the same phase — must NOT increment again
const r2 = runSdkQuery(['phase.complete', '5'], tmpDir);
assert.ok(r2.success, `second call failed: ${r2.error}`);
const stateAfter2 = fs.readFileSync(statePath, 'utf8');
const match2 = stateAfter2.match(/completed_phases:\s*(\d+)/);
assert.ok(match2, 'completed_phases not found in frontmatter after second call');
assert.equal(
Number(match2[1]),
2,
`After second call (same phase): completed_phases must remain 2 (idempotent), got ${match2[1]}`,
);
});
test('frontmatter stopped_at is updated after phase.complete', () => {
// Bug: stopped_at stays as "Completed 05-03-PLAN.md" after phase.complete 5
setupProject(tmpDir);
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const r = runSdkQuery(['phase.complete', '5'], tmpDir);
assert.ok(r.success, `call failed: ${r.error}`);
const state = fs.readFileSync(statePath, 'utf8');
// stopped_at should now reflect phase 5 completion, not the old plan progress
const stoppedMatch = state.match(/stopped_at:\s*(.+)/);
assert.ok(stoppedMatch, 'stopped_at not found in frontmatter');
assert.ok(
!stoppedMatch[1].includes('05-03-PLAN.md'),
`stopped_at should not still say "Completed 05-03-PLAN.md" — got: ${stoppedMatch[1]}`,
);
assert.ok(
stoppedMatch[1].toLowerCase().includes('phase 5') ||
stoppedMatch[1].toLowerCase().includes('complete'),
`stopped_at should reference phase 5 completion, got: ${stoppedMatch[1]}`,
);
});
test('frontmatter last_updated is refreshed to today after phase.complete', () => {
// Bug: last_updated stays as 2026-05-10T08:00:00.000Z (stale)
setupProject(tmpDir);
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const r = runSdkQuery(['phase.complete', '5'], tmpDir);
assert.ok(r.success, `call failed: ${r.error}`);
const state = fs.readFileSync(statePath, 'utf8');
const lastUpdatedMatch = state.match(/last_updated:\s*(.+)/);
assert.ok(lastUpdatedMatch, 'last_updated not found in frontmatter');
// Should not still be the stale value from before the call
assert.notEqual(
lastUpdatedMatch[1].trim(),
'2026-05-10T08:00:00.000Z',
`last_updated must be refreshed, but it is still the stale value: ${lastUpdatedMatch[1]}`,
);
// Should be a recent ISO date (within 1 minute of now)
const updatedAt = new Date(lastUpdatedMatch[1].trim());
const now = new Date();
const diffMs = Math.abs(now - updatedAt);
assert.ok(
diffMs < 60_000,
`last_updated should be approximately now (within 60s), got: ${lastUpdatedMatch[1]} (diff: ${diffMs}ms)`,
);
});
test('frontmatter total_plans is updated from ROADMAP plan counts after phase.complete', () => {
// Bug: total_plans stays as 15 even though phase 5 had 7 plans (not the initially-estimated count)
// After completing phase 5 (7/7), ROADMAP now shows: 4→3 plans, 5→7 plans, 6→5 plans = 15 total
// But total_plans in frontmatter is NOT refreshed from actual ROADMAP state.
setupProject(tmpDir);
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const r = runSdkQuery(['phase.complete', '5'], tmpDir);
assert.ok(r.success, `call failed: ${r.error}`);
const state = fs.readFileSync(statePath, 'utf8');
const match = state.match(/total_plans:\s*(\d+)/);
assert.ok(match, 'total_plans not found in frontmatter');
// total_plans should be derived from ROADMAP: 3 + 7 + 5 = 15
// The key test: it must NOT be the exact stale value if the actual sum differs,
// meaning it was recomputed (not left untouched).
// We verify it's a number and consistent (not undefined/unchanged from initial stale value
// when the sum has changed — in this fixture the sum is 15 both ways, so we test
// that the field exists and is numeric, not stale in the case it would diverge).
const totalPlans = Number(match[1]);
assert.ok(Number.isFinite(totalPlans) && totalPlans > 0, `total_plans must be a positive number, got: ${match[1]}`);
});
test('frontmatter completed_plans is updated from SUMMARY file count after phase.complete', () => {
// Bug: completed_plans stays as 6 even though completing phase 5 adds 7 more summaries = 13 total
setupProject(tmpDir);
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const r = runSdkQuery(['phase.complete', '5'], tmpDir);
assert.ok(r.success, `call failed: ${r.error}`);
const state = fs.readFileSync(statePath, 'utf8');
const match = state.match(/completed_plans:\s*(\d+)/);
assert.ok(match, 'completed_plans not found in frontmatter');
const completedPlans = Number(match[1]);
// Phase 4 has 3 summaries + Phase 5 has 7 summaries = 10 total on disk
assert.equal(
completedPlans,
10,
`completed_plans should be 10 (3 phase-4 summaries + 7 phase-5 summaries), got: ${completedPlans}`,
);
});
test('frontmatter percent is recomputed from fresh derived counts', () => {
// Bug: percent is recomputed but from the blind-incremented completed_phases,
// not from the ROADMAP-derived value.
setupProject(tmpDir);
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const r = runSdkQuery(['phase.complete', '5'], tmpDir);
assert.ok(r.success, `call failed: ${r.error}`);
const state = fs.readFileSync(statePath, 'utf8');
const match = state.match(/percent:\s*(\d+)/);
assert.ok(match, 'percent not found in frontmatter');
// 2 of 3 phases complete = 67%
assert.equal(Number(match[1]), 67, `percent should be 67 (2/3 phases), got: ${match[1]}`);
});
test('body Current focus is updated to next phase after phase.complete', () => {
// Bug: "Current focus: Phase 5 — Core API" is never updated
setupProject(tmpDir);
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const r = runSdkQuery(['phase.complete', '5'], tmpDir);
assert.ok(r.success, `call failed: ${r.error}`);
const state = fs.readFileSync(statePath, 'utf8');
// After completing phase 5, current focus should reference phase 6
assert.ok(
!state.includes('Current focus:** Phase 5') && !state.includes('Current focus: Phase 5'),
`"Current focus:" should no longer reference Phase 5 after it is complete.\nState:\n${state}`,
);
});
test('body By Phase table row for completed phase shows correct plan count', () => {
// Bug: By Phase table row for phase 5 doesn't show the actual 7/7 plans
setupProject(tmpDir);
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const r = runSdkQuery(['phase.complete', '5'], tmpDir);
assert.ok(r.success, `call failed: ${r.error}`);
const state = fs.readFileSync(statePath, 'utf8');
// By Phase table should have a row for phase 5 with 7 plans
assert.match(
state,
/\|\s*5\s*\|\s*7\s*\|/,
`By Phase table should have a row for phase 5 with 7 summaries.\nState:\n${state}`,
);
});
test('full consistency check: all STATE.md fields are coherent after phase.complete', () => {
// Integration assertion: after phase.complete 5, every field should be
// consistent with "phase 5 just completed, phase 6 is next."
setupProject(tmpDir);
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const r = runSdkQuery(['phase.complete', '5'], tmpDir);
assert.ok(r.success, `call failed: ${r.error}`);
assert.equal(r.data?.state_updated, true, 'state_updated must be true');
const state = fs.readFileSync(statePath, 'utf8');
// Frontmatter checks
assert.match(state, /completed_phases:\s*2/, 'completed_phases must be 2 (4 and 5 complete)');
assert.match(state, /percent:\s*67/, 'percent must be 67%');
// Body checks
assert.match(state, /Status:\s*Ready to plan/, 'Status must be "Ready to plan" (next phase exists)');
// Phase 6 should appear in state (next phase); accept zero-padded "06" too
const hasPhase6 = /Phase:\s*0?6/.test(state) || /current_phase:\s*0?6/.test(state);
assert.ok(hasPhase6, `STATE.md must reference Phase 6 as current after completing Phase 5.\nState:\n${state}`);
});
});

View File

@@ -1,159 +0,0 @@
/**
* Bug #3601: `phase remove N` for an integer phase can also delete the
* adjacent decimal phase section (`### Phase N.1:`) when the decimal is a
* peer-level header at the same depth as the integer being removed.
*
* Root cause: the section-removal regex in
* `get-shit-done/bin/lib/phase.cjs:updateRoadmapAfterPhaseRemoval` used a
* depth-blind lookahead (`(?=\n#{2,4}\s+Phase\s+\d+\s*:|$)`) that required
* the next header's digits to be followed by `\s*:`. `### Phase 2.1:`
* (depth 3, decimal) did not satisfy `\d+\s*:` because of the `.1`, so
* the non-greedy match consumed `Phase 2.1` along with `Phase 2` until it
* reached the next integer header.
*
* The fix makes the lookahead depth-aware: it captures the hash count of
* the header being removed and stops only at a subsequent header of the
* SAME depth, integer or decimal. That preserves the #3355 contract
* (`#### Phase 27.1:` at depth 4 is a CHILD of `### Phase 27:` at depth 3
* and must be removed alongside it) while fixing the #3601 contract
* (`### Phase 2.1:` at depth 3 is a PEER of `### Phase 2:` and must be
* preserved).
*
* Assertions go through the typed `roadmap get-phase --json` query so no
* test asserts on raw ROADMAP.md text content.
*/
'use strict';
process.env.GSD_TEST_MODE = '1';
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`,
);
}
function ensurePhaseDir(tmpDir, name) {
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', name), { recursive: true });
}
function getPhase(tmpDir, phaseNum) {
const r = runGsdTools(['roadmap', 'get-phase', phaseNum, '--json'], tmpDir);
if (!r.success) return { found: false, error: r.error };
return JSON.parse(r.output);
}
describe('bug #3601: phase remove preserves peer-depth decimal sections', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject('bug-3601-');
});
afterEach(() => {
cleanup(tmpDir);
});
test('removing Phase 2 preserves peer-depth Phase 2.1 and renumbers Phase 3 → 2', () => {
writeState(tmpDir, 'v1.0.0');
writeRoadmap(
tmpDir,
[
'# Roadmap',
'',
'## Current Milestone: v1.0.0 - Test',
'',
'### Phase 2: Parent',
'**Goal:** RemoveMeGoal',
'',
'### Phase 2.1: Follow-up',
'**Goal:** PreserveDecimalGoal',
'',
'### Phase 3: Trailing',
'**Goal:** PreserveTrailingGoal',
'',
].join('\n'),
);
ensurePhaseDir(tmpDir, '02-parent');
ensurePhaseDir(tmpDir, '02.1-follow-up');
ensurePhaseDir(tmpDir, '03-trailing');
const r = runGsdTools(['phase', 'remove', '2'], tmpDir);
assert.ok(r.success, `phase remove failed: ${r.error || r.output}`);
// The peer-depth decimal (Phase 2.1) must still be queryable — its
// unique goal proves the section body survived.
const decimal = getPhase(tmpDir, '2.1');
assert.strictEqual(decimal.found, true, 'Phase 2.1 deleted alongside Phase 2');
assert.strictEqual(decimal.phase_name, 'Follow-up');
assert.strictEqual(decimal.goal, 'PreserveDecimalGoal');
// Phase 3 must have been renumbered to Phase 2.
const renumbered = getPhase(tmpDir, '2');
assert.strictEqual(renumbered.found, true);
assert.strictEqual(renumbered.phase_name, 'Trailing');
assert.strictEqual(
renumbered.goal,
'PreserveTrailingGoal',
'Phase 3 → Phase 2 renumber did not carry the right section content',
);
// The removed Parent goal must not be retrievable from any current phase.
const parentLookup = getPhase(tmpDir, '3');
assert.notStrictEqual(
parentLookup.goal,
'RemoveMeGoal',
'removed parent goal reappeared under a phase header',
);
});
test('removing Phase 5 preserves Phase 5.1 and Phase 5.2 (multiple peer decimals)', () => {
writeState(tmpDir, 'v1.0.0');
writeRoadmap(
tmpDir,
[
'# Roadmap',
'',
'## Current Milestone: v1.0.0 - Test',
'',
'### Phase 5: Parent',
'**Goal:** RemoveParent',
'',
'### Phase 5.1: First child',
'**Goal:** ChildAGoal',
'',
'### Phase 5.2: Second child',
'**Goal:** ChildBGoal',
'',
'### Phase 6: Tail',
'**Goal:** TailGoal',
'',
].join('\n'),
);
ensurePhaseDir(tmpDir, '05-parent');
ensurePhaseDir(tmpDir, '05.1-first-child');
ensurePhaseDir(tmpDir, '05.2-second-child');
ensurePhaseDir(tmpDir, '06-tail');
const r = runGsdTools(['phase', 'remove', '5'], tmpDir);
assert.ok(r.success);
const decimalA = getPhase(tmpDir, '5.1');
assert.strictEqual(decimalA.found, true, 'Phase 5.1 deleted');
assert.strictEqual(decimalA.goal, 'ChildAGoal');
const decimalB = getPhase(tmpDir, '5.2');
assert.strictEqual(decimalB.found, true, 'Phase 5.2 deleted');
assert.strictEqual(decimalB.goal, 'ChildBGoal');
const tail = getPhase(tmpDir, '5');
assert.strictEqual(tail.found, true);
assert.strictEqual(tail.goal, 'TailGoal', 'Phase 6 → Phase 5 renumber misfired');
});
});

View File

@@ -1,179 +0,0 @@
/**
* Bug #3602: After `phase remove N`, ROADMAP references to slugged plan
* files (`07-01-cherry-pick-foundation-PLAN.md`) keep their old phase
* prefix while the on-disk file has already been renamed. The
* renumber-references regex in `phase.cjs` only matched compact forms
* (`07-01-PLAN.md`, bare `07-01`) — it did not allow a slug between the
* plan number and the `-PLAN.md` / `-SUMMARY.md` suffix.
*
* Fix: extend the lookahead to allow an optional `-<slug>` segment before
* the `-(PLAN|SUMMARY).md` suffix while still preserving the "bare token,
* not part of a longer number/identifier" alternative.
*
* Assertions go through the typed `roadmap get-phase --json` query so no
* test asserts on raw ROADMAP.md text content.
*/
'use strict';
process.env.GSD_TEST_MODE = '1';
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`,
);
}
function ensurePhaseDir(tmpDir, name) {
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', name), { recursive: true });
}
function ensurePlanFile(tmpDir, phaseDirName, planName) {
const p = path.join(tmpDir, '.planning', 'phases', phaseDirName, planName);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, '# Plan\n');
}
function getPhase(tmpDir, phaseNum) {
const r = runGsdTools(['roadmap', 'get-phase', phaseNum, '--json'], tmpDir);
if (!r.success) return { found: false, error: r.error };
return JSON.parse(r.output);
}
describe('bug #3602: phase remove renumbers slugged plan references in ROADMAP', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject('bug-3602-');
});
afterEach(() => {
cleanup(tmpDir);
});
test('slugged PLAN reference (07-01-cherry-pick-foundation-PLAN.md) is renumbered to 06-01-…', () => {
writeState(tmpDir, 'v1.0.0');
writeRoadmap(
tmpDir,
[
'# Roadmap',
'',
'## Current Milestone: v1.0.0',
'',
'### Phase 6: Old Work',
'**Goal:** RemoveThisGoal',
'',
'### Phase 7: New Work',
'**Goal:** Plans: 07-01-cherry-pick-foundation-PLAN.md and 07-02-finish-it-SUMMARY.md',
'',
].join('\n'),
);
ensurePhaseDir(tmpDir, '06-old');
ensurePhaseDir(tmpDir, '07-new');
ensurePlanFile(tmpDir, '07-new', '07-01-cherry-pick-foundation-PLAN.md');
ensurePlanFile(tmpDir, '07-new', '07-02-finish-it-SUMMARY.md');
const r = runGsdTools(['phase', 'remove', '6'], tmpDir);
assert.ok(r.success, `phase remove failed: ${r.error || r.output}`);
// Phase 7 → Phase 6 after removal. The renumbered phase's recorded
// goal must reference the renumbered plan filenames (06-01-… and
// 06-02-…), not the stale 07-01-… / 07-02-… form.
const phase6 = getPhase(tmpDir, '6');
assert.strictEqual(phase6.found, true);
assert.strictEqual(phase6.phase_name, 'New Work');
assert.ok(
phase6.goal.includes('06-01-cherry-pick-foundation-PLAN.md'),
`Plans reference for slugged PLAN was not renumbered. Goal: ${phase6.goal}`,
);
assert.ok(
phase6.goal.includes('06-02-finish-it-SUMMARY.md'),
`Plans reference for slugged SUMMARY was not renumbered. Goal: ${phase6.goal}`,
);
assert.ok(
!phase6.goal.includes('07-01'),
`stale 07-01 prefix remains in ROADMAP. Goal: ${phase6.goal}`,
);
assert.ok(
!phase6.goal.includes('07-02'),
`stale 07-02 prefix remains in ROADMAP. Goal: ${phase6.goal}`,
);
});
test('compact PLAN/SUMMARY reference (07-01-PLAN.md) still renumbers (#3601 contract preserved)', () => {
writeState(tmpDir, 'v1.0.0');
writeRoadmap(
tmpDir,
[
'# Roadmap',
'',
'## Current Milestone: v1.0.0',
'',
'### Phase 6: Old',
'**Goal:** RemoveGoal',
'',
'### Phase 7: New',
'**Goal:** Plans: 07-01-PLAN.md and 07-02-SUMMARY.md',
'',
].join('\n'),
);
ensurePhaseDir(tmpDir, '06-old');
ensurePhaseDir(tmpDir, '07-new');
ensurePlanFile(tmpDir, '07-new', '07-01-PLAN.md');
ensurePlanFile(tmpDir, '07-new', '07-02-SUMMARY.md');
const r = runGsdTools(['phase', 'remove', '6'], tmpDir);
assert.ok(r.success);
const phase6 = getPhase(tmpDir, '6');
assert.strictEqual(phase6.found, true);
assert.ok(phase6.goal.includes('06-01-PLAN.md'));
assert.ok(phase6.goal.includes('06-02-SUMMARY.md'));
assert.ok(!phase6.goal.includes('07-01-PLAN.md'));
assert.ok(!phase6.goal.includes('07-02-SUMMARY.md'));
});
test('does NOT renumber values that look like phase-plan tokens but are not (e.g. 2026-01-01 dates)', () => {
// Counter-test: an ISO date `2026-01-01` should NOT be matched by the
// renumber regex even though it superficially looks like NN-NN-NN.
// The negative lookbehind `(?<![0-9-])` and trailing lookahead must
// protect against false positives in dates and other digit clusters.
writeState(tmpDir, 'v1.0.0');
writeRoadmap(
tmpDir,
[
'# Roadmap',
'',
'## Current Milestone: v1.0.0',
'',
'### Phase 6: Old',
'**Goal:** RemoveGoal',
'',
'### Phase 7: Date safety',
'**Goal:** Created 2026-01-01 and tagged v1-2-3 — must not renumber',
'',
].join('\n'),
);
ensurePhaseDir(tmpDir, '06-old');
ensurePhaseDir(tmpDir, '07-new');
const r = runGsdTools(['phase', 'remove', '6'], tmpDir);
assert.ok(r.success);
const phase6 = getPhase(tmpDir, '6');
assert.strictEqual(phase6.found, true);
assert.ok(
phase6.goal.includes('2026-01-01'),
`ISO date 2026-01-01 was wrongly modified. Goal: ${phase6.goal}`,
);
assert.ok(
phase6.goal.includes('v1-2-3'),
`version-tag v1-2-3 was wrongly modified. Goal: ${phase6.goal}`,
);
});
});

View File

@@ -1,173 +0,0 @@
/**
* Integration tests for auto-prune on phase completion (#2087).
*
* When config `workflow.auto_prune_state` is true, `phase complete`
* should automatically prune STATE.md as part of the phase transition.
*/
'use strict';
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
function writeConfig(tmpDir, config) {
fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify(config, null, 2));
}
function writeStateMd(tmpDir, content) {
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content);
}
function readStateMd(tmpDir) {
return fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
}
function writeRoadmap(tmpDir, content) {
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), content);
}
function setupPhase(tmpDir, phaseNum, planCount) {
const phasesDir = path.join(tmpDir, '.planning', 'phases');
const phaseDir = path.join(phasesDir, `${String(phaseNum).padStart(2, '0')}-test-phase`);
fs.mkdirSync(phaseDir, { recursive: true });
for (let i = 1; i <= planCount; i++) {
const planId = `${String(phaseNum).padStart(2, '0')}-${String(i).padStart(2, '0')}`;
fs.writeFileSync(path.join(phaseDir, `${planId}-PLAN.md`), `# Plan ${planId}\n`);
fs.writeFileSync(path.join(phaseDir, `${planId}-SUMMARY.md`), `# Summary ${planId}\n`);
}
}
describe('phase complete auto-prune (#2087)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('prunes STATE.md automatically when auto_prune_state is true', () => {
writeConfig(tmpDir, {
workflow: { auto_prune_state: true },
});
writeStateMd(tmpDir, [
'# Session State',
'',
'**Current Phase:** 6',
'**Status:** Executing',
'',
'## Decisions',
'',
'- [Phase 1]: Old decision from phase 1',
'- [Phase 2]: Old decision from phase 2',
'- [Phase 5]: Recent decision',
'- [Phase 6]: Current decision',
'',
].join('\n'));
writeRoadmap(tmpDir, [
'# Roadmap',
'',
'## Phase 6: Test Phase',
'',
'**Plans:** 0/2',
'',
].join('\n'));
setupPhase(tmpDir, 6, 2);
const result = runGsdTools('phase complete 6', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const newState = readStateMd(tmpDir);
// With keep-recent=3 (default), cutoff = 6-3 = 3
// Phase 1 and 2 decisions should be pruned
assert.doesNotMatch(newState, /\[Phase 1\]: Old decision/);
assert.doesNotMatch(newState, /\[Phase 2\]: Old decision/);
// Phase 5 and 6 should remain
assert.match(newState, /\[Phase 5\]: Recent decision/);
assert.match(newState, /\[Phase 6\]: Current decision/);
});
test('does NOT prune when auto_prune_state is false (default)', () => {
writeConfig(tmpDir, {
workflow: { auto_prune_state: false },
});
writeStateMd(tmpDir, [
'# Session State',
'',
'**Current Phase:** 6',
'**Status:** Executing',
'',
'## Decisions',
'',
'- [Phase 1]: Old decision from phase 1',
'- [Phase 5]: Recent decision',
'- [Phase 6]: Current decision',
'',
].join('\n'));
writeRoadmap(tmpDir, [
'# Roadmap',
'',
'## Phase 6: Test Phase',
'',
'**Plans:** 0/2',
'',
].join('\n'));
setupPhase(tmpDir, 6, 2);
const result = runGsdTools('phase complete 6', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const newState = readStateMd(tmpDir);
// Phase 1 decision should still be present (no pruning)
assert.match(newState, /\[Phase 1\]: Old decision/);
});
test('does NOT prune when auto_prune_state is absent from config', () => {
writeConfig(tmpDir, {
workflow: {},
});
writeStateMd(tmpDir, [
'# Session State',
'',
'**Current Phase:** 6',
'**Status:** Executing',
'',
'## Decisions',
'',
'- [Phase 1]: Old decision from phase 1',
'- [Phase 6]: Current decision',
'',
].join('\n'));
writeRoadmap(tmpDir, [
'# Roadmap',
'',
'## Phase 6: Test Phase',
'',
'**Plans:** 0/2',
'',
].join('\n'));
setupPhase(tmpDir, 6, 2);
const result = runGsdTools('phase complete 6', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const newState = readStateMd(tmpDir);
// Should not prune — absent means disabled (default: false)
assert.match(newState, /\[Phase 1\]: Old decision/);
});
});

File diff suppressed because it is too large Load Diff