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:
@@ -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.
|
||||
22
.changeset/3740-consolidate-phase-tests.md
Normal file
22
.changeset/3740-consolidate-phase-tests.md
Normal 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
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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)'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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}"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
1595
tests/phase.test.cjs
1595
tests/phase.test.cjs
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user