fix(2137): skip worktree isolation when .gitmodules detected (#2144)
* feat(sdk): add typed query foundation and gsd-sdk query (Phase 1) Add sdk/src/query registry and handlers with tests, GSDQueryError, CLI query wiring, and supporting type/tool-scoping hooks. Update CHANGELOG. Vitest 4 constructor mock fixes in milestone-runner tests. Made-with: Cursor * fix(2137): skip worktree isolation when .gitmodules detected When a project contains git submodules, worktree isolation cannot correctly handle submodule commits — three separate gaps exist in worktree setup, executor commit protocol, and merge-back. Rather than patch each gap individually, detect .gitmodules at phase start and fall back to sequential execution, which handles submodules transparently (Option B). Affected workflows: execute-phase.md, quick.md --------- Co-authored-by: David Sienkowski <dave@sienkowski.com>
This commit is contained in:
275
sdk/src/query/roadmap.test.ts
Normal file
275
sdk/src/query/roadmap.test.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Unit tests for roadmap query handlers.
|
||||
*
|
||||
* Tests roadmapAnalyze, roadmapGetPhase, getMilestoneInfo,
|
||||
* extractCurrentMilestone, and stripShippedMilestones.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
// These will be imported once roadmap.ts is created
|
||||
import {
|
||||
roadmapAnalyze,
|
||||
roadmapGetPhase,
|
||||
getMilestoneInfo,
|
||||
extractCurrentMilestone,
|
||||
stripShippedMilestones,
|
||||
} from './roadmap.js';
|
||||
|
||||
// ─── Test fixtures ────────────────────────────────────────────────────────
|
||||
|
||||
const ROADMAP_CONTENT = `# Roadmap
|
||||
|
||||
## Current Milestone: v3.0 SDK-First Migration
|
||||
|
||||
**Goal:** Migrate all deterministic orchestration into TypeScript SDK.
|
||||
|
||||
- [x] **Phase 9: Foundation and Test Infrastructure**
|
||||
- [ ] **Phase 10: Read-Only Queries**
|
||||
- [ ] **Phase 11: Mutations**
|
||||
|
||||
### Phase 9: Foundation and Test Infrastructure
|
||||
|
||||
**Goal:** Build core SDK infrastructure.
|
||||
|
||||
**Depends on:** None
|
||||
|
||||
**Success Criteria**:
|
||||
1. Error classification system exists
|
||||
2. Query registry works
|
||||
|
||||
### Phase 10: Read-Only Queries
|
||||
|
||||
**Goal:** Port read-only query operations.
|
||||
|
||||
**Depends on:** Phase 9
|
||||
|
||||
**Success Criteria**:
|
||||
1. All read queries work
|
||||
2. Golden file tests pass
|
||||
|
||||
### Phase 11: Mutations
|
||||
|
||||
**Goal:** Port mutation operations.
|
||||
|
||||
**Depends on:** Phase 10
|
||||
`;
|
||||
|
||||
const STATE_WITH_MILESTONE = `---
|
||||
gsd_state_version: 1.0
|
||||
milestone: v3.0
|
||||
status: executing
|
||||
---
|
||||
|
||||
# Project State
|
||||
|
||||
**Current Phase:** 10
|
||||
**Status:** Ready to execute
|
||||
`;
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'roadmap-test-'));
|
||||
await mkdir(join(tmpDir, '.planning', 'phases', '09-foundation'), { recursive: true });
|
||||
await mkdir(join(tmpDir, '.planning', 'phases', '10-read-only-queries'), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ─── stripShippedMilestones ───────────────────────────────────────────────
|
||||
|
||||
describe('stripShippedMilestones', () => {
|
||||
it('removes <details> blocks', () => {
|
||||
const content = 'before\n<details>\nshipped content\n</details>\nafter';
|
||||
expect(stripShippedMilestones(content)).toBe('before\n\nafter');
|
||||
});
|
||||
|
||||
it('handles multiple <details> blocks', () => {
|
||||
const content = '<details>a</details>middle<details>b</details>end';
|
||||
expect(stripShippedMilestones(content)).toBe('middleend');
|
||||
});
|
||||
|
||||
it('returns content unchanged when no details blocks', () => {
|
||||
expect(stripShippedMilestones('no details here')).toBe('no details here');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getMilestoneInfo ─────────────────────────────────────────────────────
|
||||
|
||||
describe('getMilestoneInfo', () => {
|
||||
it('extracts version and name from heading format', async () => {
|
||||
await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), ROADMAP_CONTENT);
|
||||
const info = await getMilestoneInfo(tmpDir);
|
||||
expect(info.version).toBe('v3.0');
|
||||
expect(info.name).toBe('SDK-First Migration');
|
||||
});
|
||||
|
||||
it('extracts from in-progress marker format', async () => {
|
||||
const roadmap = '- \u{1F6A7} **v2.1 Belgium** \u2014 Phases 24-28 (in progress)';
|
||||
await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
|
||||
const info = await getMilestoneInfo(tmpDir);
|
||||
expect(info.version).toBe('v2.1');
|
||||
expect(info.name).toBe('Belgium');
|
||||
});
|
||||
|
||||
it('falls back to v1.0 when ROADMAP.md missing', async () => {
|
||||
const info = await getMilestoneInfo(tmpDir);
|
||||
expect(info.version).toBe('v1.0');
|
||||
expect(info.name).toBe('milestone');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── extractCurrentMilestone ──────────────────────────────────────────────
|
||||
|
||||
describe('extractCurrentMilestone', () => {
|
||||
it('scopes content to current milestone from STATE.md version', async () => {
|
||||
await writeFile(join(tmpDir, '.planning', 'STATE.md'), STATE_WITH_MILESTONE);
|
||||
await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), ROADMAP_CONTENT);
|
||||
const result = await extractCurrentMilestone(ROADMAP_CONTENT, tmpDir);
|
||||
expect(result).toContain('Phase 10');
|
||||
expect(result).toContain('v3.0');
|
||||
});
|
||||
|
||||
it('strips shipped milestones when no cwd version found', async () => {
|
||||
const content = '<details>old</details>current content';
|
||||
// No STATE.md, no in-progress marker
|
||||
const result = await extractCurrentMilestone(content, tmpDir);
|
||||
expect(result).toBe('current content');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── roadmapGetPhase ──────────────────────────────────────────────────────
|
||||
|
||||
describe('roadmapGetPhase', () => {
|
||||
it('returns phase info for existing phase', async () => {
|
||||
await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), ROADMAP_CONTENT);
|
||||
await writeFile(join(tmpDir, '.planning', 'STATE.md'), STATE_WITH_MILESTONE);
|
||||
const result = await roadmapGetPhase(['10'], tmpDir);
|
||||
const data = result.data as Record<string, unknown>;
|
||||
expect(data.found).toBe(true);
|
||||
expect(data.phase_number).toBe('10');
|
||||
expect(data.phase_name).toBe('Read-Only Queries');
|
||||
expect(data.goal).toBe('Port read-only query operations.');
|
||||
expect((data.success_criteria as string[]).length).toBe(2);
|
||||
expect(data.section).toContain('### Phase 10');
|
||||
});
|
||||
|
||||
it('returns { found: false } for nonexistent phase', async () => {
|
||||
await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), ROADMAP_CONTENT);
|
||||
await writeFile(join(tmpDir, '.planning', 'STATE.md'), STATE_WITH_MILESTONE);
|
||||
const result = await roadmapGetPhase(['999'], tmpDir);
|
||||
const data = result.data as Record<string, unknown>;
|
||||
expect(data.found).toBe(false);
|
||||
expect(data.phase_number).toBe('999');
|
||||
});
|
||||
|
||||
it('throws GSDError when no phase number provided', async () => {
|
||||
await expect(roadmapGetPhase([], tmpDir)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('handles malformed roadmap (checklist-only, no detail section)', async () => {
|
||||
const malformed = `# Roadmap\n\n- [ ] **Phase 99: Missing Detail**\n`;
|
||||
await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), malformed);
|
||||
const result = await roadmapGetPhase(['99'], tmpDir);
|
||||
const data = result.data as Record<string, unknown>;
|
||||
expect(data.error).toBe('malformed_roadmap');
|
||||
expect(data.phase_name).toBe('Missing Detail');
|
||||
});
|
||||
|
||||
it('returns error object when ROADMAP.md not found', async () => {
|
||||
const result = await roadmapGetPhase(['10'], tmpDir);
|
||||
const data = result.data as Record<string, unknown>;
|
||||
expect(data.found).toBe(false);
|
||||
expect(data.error).toBe('ROADMAP.md not found');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── roadmapAnalyze ───────────────────────────────────────────────────────
|
||||
|
||||
describe('roadmapAnalyze', () => {
|
||||
it('returns full analysis for valid roadmap', async () => {
|
||||
await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), ROADMAP_CONTENT);
|
||||
await writeFile(join(tmpDir, '.planning', 'STATE.md'), STATE_WITH_MILESTONE);
|
||||
|
||||
// Create some plan/summary files for disk correlation
|
||||
await writeFile(join(tmpDir, '.planning', 'phases', '09-foundation', '09-01-PLAN.md'), '---\n---\n');
|
||||
await writeFile(join(tmpDir, '.planning', 'phases', '09-foundation', '09-01-SUMMARY.md'), '---\n---\n');
|
||||
await writeFile(join(tmpDir, '.planning', 'phases', '10-read-only-queries', '10-01-PLAN.md'), '---\n---\n');
|
||||
|
||||
const result = await roadmapAnalyze([], tmpDir);
|
||||
const data = result.data as Record<string, unknown>;
|
||||
|
||||
expect(data.phase_count).toBe(3);
|
||||
expect((data.phases as Array<Record<string, unknown>>).length).toBe(3);
|
||||
|
||||
const phases = data.phases as Array<Record<string, unknown>>;
|
||||
// Phase 9 has 1 plan, 1 summary => complete (or roadmap checkbox says complete)
|
||||
const p9 = phases.find(p => p.number === '9');
|
||||
expect(p9).toBeDefined();
|
||||
expect(p9!.name).toBe('Foundation and Test Infrastructure');
|
||||
expect(p9!.roadmap_complete).toBe(true); // [x] in checklist
|
||||
|
||||
// Phase 10 has 1 plan, 0 summaries => planned
|
||||
const p10 = phases.find(p => p.number === '10');
|
||||
expect(p10).toBeDefined();
|
||||
expect(p10!.disk_status).toBe('planned');
|
||||
expect(p10!.plan_count).toBe(1);
|
||||
|
||||
// Phase 11 has no directory content
|
||||
const p11 = phases.find(p => p.number === '11');
|
||||
expect(p11).toBeDefined();
|
||||
expect(p11!.disk_status).toBe('no_directory');
|
||||
|
||||
expect(data.total_plans).toBeGreaterThan(0);
|
||||
expect(typeof data.progress_percent).toBe('number');
|
||||
});
|
||||
|
||||
it('returns error when ROADMAP.md not found', async () => {
|
||||
const result = await roadmapAnalyze([], tmpDir);
|
||||
const data = result.data as Record<string, unknown>;
|
||||
expect(data.error).toBe('ROADMAP.md not found');
|
||||
});
|
||||
|
||||
it('overrides disk_status to complete when roadmap checkbox is checked', async () => {
|
||||
await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), ROADMAP_CONTENT);
|
||||
await writeFile(join(tmpDir, '.planning', 'STATE.md'), STATE_WITH_MILESTONE);
|
||||
|
||||
// Phase 9 dir is empty (no plans/summaries) but roadmap has [x]
|
||||
const result = await roadmapAnalyze([], tmpDir);
|
||||
const data = result.data as Record<string, unknown>;
|
||||
const phases = data.phases as Array<Record<string, unknown>>;
|
||||
const p9 = phases.find(p => p.number === '9');
|
||||
expect(p9!.disk_status).toBe('complete');
|
||||
expect(p9!.roadmap_complete).toBe(true);
|
||||
});
|
||||
|
||||
it('detects missing phase details from checklist', async () => {
|
||||
const roadmapWithExtra = ROADMAP_CONTENT + '\n- [ ] **Phase 99: Future Phase**\n';
|
||||
await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), roadmapWithExtra);
|
||||
await writeFile(join(tmpDir, '.planning', 'STATE.md'), STATE_WITH_MILESTONE);
|
||||
|
||||
const result = await roadmapAnalyze([], tmpDir);
|
||||
const data = result.data as Record<string, unknown>;
|
||||
expect(data.missing_phase_details).toContain('99');
|
||||
});
|
||||
|
||||
it('handles repeated calls correctly (no lastIndex bug)', async () => {
|
||||
await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), ROADMAP_CONTENT);
|
||||
await writeFile(join(tmpDir, '.planning', 'STATE.md'), STATE_WITH_MILESTONE);
|
||||
|
||||
const result1 = await roadmapAnalyze([], tmpDir);
|
||||
const result2 = await roadmapAnalyze([], tmpDir);
|
||||
const data1 = result1.data as Record<string, unknown>;
|
||||
const data2 = result2.data as Record<string, unknown>;
|
||||
|
||||
expect((data1.phases as unknown[]).length).toBe((data2.phases as unknown[]).length);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user