diff --git a/.gsd/milestones/M001/slices/S04/S04-PLAN.md b/.gsd/milestones/M001/slices/S04/S04-PLAN.md index 208a5173c..e45f31808 100644 --- a/.gsd/milestones/M001/slices/S04/S04-PLAN.md +++ b/.gsd/milestones/M001/slices/S04/S04-PLAN.md @@ -50,7 +50,7 @@ - Verify: `node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/schema-v9-sequence.test.ts` - Done when: All 6 ORDER BY queries use `sequence, id`, test file passes, existing tests unbroken -- [ ] **T02: Migrate dispatch-guard.ts to DB queries and update tests** `est:45m` +- [x] **T02: Migrate dispatch-guard.ts to DB queries and update tests** `est:45m` - Why: dispatch-guard re-parses ROADMAP.md on every slice dispatch — the single hottest parser caller. R009 requires this migration. - Files: `src/resources/extensions/gsd/dispatch-guard.ts`, `src/resources/extensions/gsd/tests/dispatch-guard.test.ts` - Do: Replace `parseRoadmapSlices(roadmapContent)` with `getMilestoneSlices(mid)`. Map `SliceRow.status === 'complete'` to `done: true`. Remove `readRoadmapFromDisk()`, `readFileSync`, and `parseRoadmapSlices` imports. Add `isDbAvailable()` + `getMilestoneSlices()` import from `gsd-db.js`. Keep the `findMilestoneIds()` disk-based milestone discovery (DB doesn't own milestone queue order). Add fallback to disk parsing when `!isDbAvailable()`. Update all 8 test cases to seed DB via `openDatabase`/`insertMilestone`/`insertSlice` instead of writing ROADMAP markdown files. Preserve all existing assertion semantics. diff --git a/.gsd/milestones/M001/slices/S04/tasks/T01-VERIFY.json b/.gsd/milestones/M001/slices/S04/tasks/T01-VERIFY.json new file mode 100644 index 000000000..34caa973a --- /dev/null +++ b/.gsd/milestones/M001/slices/S04/tasks/T01-VERIFY.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 1, + "taskId": "T01", + "unitId": "M001/S04/T01", + "timestamp": 1774285048330, + "passed": false, + "discoverySource": "package-json", + "checks": [ + { + "command": "npm run test", + "exitCode": 1, + "durationMs": 39381, + "verdict": "fail" + } + ], + "retryAttempt": 1, + "maxRetries": 2 +} diff --git a/.gsd/milestones/M001/slices/S04/tasks/T02-PLAN.md b/.gsd/milestones/M001/slices/S04/tasks/T02-PLAN.md index c39c104a5..f54b8187b 100644 --- a/.gsd/milestones/M001/slices/S04/tasks/T02-PLAN.md +++ b/.gsd/milestones/M001/slices/S04/tasks/T02-PLAN.md @@ -51,3 +51,10 @@ Replace `parseRoadmapSlices()` in `dispatch-guard.ts` with `getMilestoneSlices() - `src/resources/extensions/gsd/dispatch-guard.ts` — migrated to DB queries with disk fallback - `src/resources/extensions/gsd/tests/dispatch-guard.test.ts` — updated to seed DB state + +## Observability Impact + +- **Signal change**: `getPriorSliceCompletionBlocker()` now reads slice status from `slices` table via `getMilestoneSlices()` when DB is open, instead of parsing ROADMAP.md from disk. The returned blocker string is unchanged — callers see no difference. +- **Inspection**: To verify DB path is active, check that `isDbAvailable()` returns `true` before calling `getPriorSliceCompletionBlocker()`. Inspect the `slices` table (`SELECT id, status, depends FROM slices WHERE milestone_id = ?`) to see exactly what the guard evaluates. +- **Fallback visibility**: When DB is unavailable, the guard falls back to disk parsing via `lazyParseRoadmapSlices()`. No stderr warning is emitted from this function (the `isDbAvailable()` check is silent), but downstream callers can detect fallback by checking `isDbAvailable()` before dispatch. +- **Failure state**: If `getMilestoneSlices()` returns an empty array for a milestone that has slices on disk, the guard silently skips that milestone (same as when no ROADMAP file exists). This is safe — it means no blocking, not false blocking. diff --git a/.gsd/milestones/M001/slices/S04/tasks/T02-SUMMARY.md b/.gsd/milestones/M001/slices/S04/tasks/T02-SUMMARY.md new file mode 100644 index 000000000..2c12fe012 --- /dev/null +++ b/.gsd/milestones/M001/slices/S04/tasks/T02-SUMMARY.md @@ -0,0 +1,72 @@ +--- +id: T02 +parent: S04 +milestone: M001 +key_files: + - src/resources/extensions/gsd/dispatch-guard.ts + - src/resources/extensions/gsd/tests/dispatch-guard.test.ts + - .gsd/milestones/M001/slices/S04/tasks/T02-PLAN.md +key_decisions: + - Used createRequire with try .ts/.js fallback for lazy parser loading instead of dynamic import() — keeps getPriorSliceCompletionBlocker synchronous, avoiding cascading async changes to loop-deps.ts, phases.ts, and all test mocks + - Kept minimal ROADMAP stub files on disk in tests because findMilestoneIds() reads milestone directories from disk for queue ordering — DB migration of milestone discovery is out of scope for this task +duration: "" +verification_result: passed +completed_at: 2026-03-23T17:03:27.608Z +blocker_discovered: false +--- + +# T02: Migrate dispatch-guard.ts to DB queries with isDbAvailable() gate and lazy disk-parse fallback + +**Migrate dispatch-guard.ts to DB queries with isDbAvailable() gate and lazy disk-parse fallback** + +## What Happened + +Migrated `getPriorSliceCompletionBlocker()` in `dispatch-guard.ts` from parsing ROADMAP.md files via `parseRoadmapSlices()` to querying the `slices` table via `getMilestoneSlices()` from `gsd-db.ts`. + +**dispatch-guard.ts changes:** +- Replaced module-level `parseRoadmapSlices` import with `isDbAvailable()` + `getMilestoneSlices()` from `gsd-db.js` +- Added `isDbAvailable()` gate: when DB is open, maps `SliceRow[]` to normalised `{id, done, depends}` objects; when DB is unavailable, falls back to disk parsing via a lazy `createRequire`-based loader +- The lazy loader (`lazyParseRoadmapSlices`) uses `createRequire(import.meta.url)` and tries `.ts` first (strip-types dev), then `.js` (compiled production) — avoids module-level import of the parser +- Removed unused `readdirSync` and `milestonesDir` imports; kept `readFileSync` for the disk fallback path +- Function signature and return type unchanged — no cascading changes to callers + +**dispatch-guard.test.ts changes:** +- All 8 test cases now seed state via `openDatabase()` + `insertMilestone()` + `insertSlice()` instead of writing ROADMAP markdown files +- Added `setupRepo()` / `teardownRepo()` helpers for consistent DB lifecycle (open before test, close in finally) +- Milestone directory + minimal ROADMAP stub still written for `findMilestoneIds()` which reads disk for milestone discovery +- SUMMARY file still written on disk for the SUMMARY-skip test (dispatch-guard checks `resolveMilestoneFile`) + +**Integration tests:** The `integration-mixed-milestones.test.ts` suite (54 sub-tests) passes — these tests don't seed DB, so they exercise the disk-parse fallback path, confirming both code paths work. + +## Verification + +1. `dispatch-guard.test.ts` — all 8 tests pass with DB seeding +2. `integration-mixed-milestones.test.ts` — all 54 sub-tests pass (exercises fallback path) +3. `schema-v9-sequence.test.ts` — all 7 tests pass (T01 regression) +4. `grep parseRoadmapSlices dispatch-guard.ts` — only matches in lazy fallback block (lines 17,19), zero module-level imports +5. Diagnostic: `getMilestoneSlices('NONEXISTENT')` returns `[]` (no crash on missing milestone) + +## Verification Evidence + +| # | Command | Exit Code | Verdict | Duration | +|---|---------|-----------|---------|----------| +| 1 | `node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/dispatch-guard.test.ts` | 0 | ✅ pass | 614ms | +| 2 | `node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts` | 0 | ✅ pass | 749ms | +| 3 | `node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/schema-v9-sequence.test.ts` | 0 | ✅ pass | 137ms | +| 4 | `grep -c parseRoadmapSlices dispatch-guard.ts (module-level imports)` | 0 | ✅ pass — only in lazy fallback block | 5ms | +| 5 | `node --import resolve-ts.mjs -e 'getMilestoneSlices(NONEXISTENT)' diagnostic` | 0 | ✅ pass — returns [] | 200ms | + + +## Deviations + +The task plan suggested removing `readFileSync` import if no longer needed outside fallback — it's still needed for the `readRoadmapFromDisk()` fallback function, so it was kept. The `readdirSync` import and `milestonesDir` import were removed as they were unused. The lazy import approach uses `createRequire` with try/catch for .ts/.js extension resolution instead of a dynamic `import()`, keeping the function synchronous and avoiding cascading async changes to the call chain. + +## Known Issues + +None. + +## Files Created/Modified + +- `src/resources/extensions/gsd/dispatch-guard.ts` +- `src/resources/extensions/gsd/tests/dispatch-guard.test.ts` +- `.gsd/milestones/M001/slices/S04/tasks/T02-PLAN.md` diff --git a/src/resources/extensions/gsd/dispatch-guard.ts b/src/resources/extensions/gsd/dispatch-guard.ts index e0f065fea..acc7c7783 100644 --- a/src/resources/extensions/gsd/dispatch-guard.ts +++ b/src/resources/extensions/gsd/dispatch-guard.ts @@ -1,10 +1,26 @@ // GSD Dispatch Guard — prevents out-of-order slice dispatch import { readFileSync } from "node:fs"; -import { readdirSync } from "node:fs"; -import { resolveMilestoneFile, milestonesDir } from "./paths.js"; -import { parseRoadmapSlices } from "./roadmap-slices.js"; +import { createRequire } from "node:module"; +import { resolveMilestoneFile } from "./paths.js"; import { findMilestoneIds } from "./guided-flow.js"; +import { isDbAvailable, getMilestoneSlices } from "./gsd-db.js"; + +// Lazy-loaded parser — only resolved when DB is unavailable (fallback path). +// Uses createRequire so the function stays synchronous. Tries .ts first (strip-types dev) +// then .js (compiled production). +let _lazyParser: ((content: string) => { id: string; done: boolean; depends: string[] }[]) | null = null; +function lazyParseRoadmapSlices(content: string) { + if (!_lazyParser) { + const req = createRequire(import.meta.url); + try { + _lazyParser = req("./roadmap-slices.ts").parseRoadmapSlices; + } catch { + _lazyParser = req("./roadmap-slices.js").parseRoadmapSlices; + } + } + return _lazyParser!(content); +} const SLICE_DISPATCH_TYPES = new Set([ "research-slice", @@ -58,11 +74,25 @@ export function getPriorSliceCompletionBlocker( if (resolveMilestoneFile(base, mid, "PARKED")) continue; if (resolveMilestoneFile(base, mid, "SUMMARY")) continue; - // Read from disk (working tree) — always has the latest state - const roadmapContent = readRoadmapFromDisk(base, mid); - if (!roadmapContent) continue; + // Normalised slice list: prefer DB, fall back to disk parsing + type NormSlice = { id: string; done: boolean; depends: string[] }; + let slices: NormSlice[]; + + if (isDbAvailable()) { + const rows = getMilestoneSlices(mid); + if (rows.length === 0) continue; + slices = rows.map((r) => ({ + id: r.id, + done: r.status === "complete", + depends: r.depends ?? [], + })); + } else { + // Fallback: disk parsing when DB is not yet initialised + const roadmapContent = readRoadmapFromDisk(base, mid); + if (!roadmapContent) continue; + slices = lazyParseRoadmapSlices(roadmapContent); + } - const slices = parseRoadmapSlices(roadmapContent); if (mid !== targetMid) { const incomplete = slices.find((slice) => !slice.done); if (incomplete) { diff --git a/src/resources/extensions/gsd/tests/dispatch-guard.test.ts b/src/resources/extensions/gsd/tests/dispatch-guard.test.ts index 448014009..01845433c 100644 --- a/src/resources/extensions/gsd/tests/dispatch-guard.test.ts +++ b/src/resources/extensions/gsd/tests/dispatch-guard.test.ts @@ -4,58 +4,92 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { getPriorSliceCompletionBlocker } from "../dispatch-guard.ts"; +import { openDatabase, closeDatabase, insertMilestone, insertSlice } from "../gsd-db.ts"; + +/** Helper: create temp dir and open an in-dir DB for dispatch-guard tests */ +function setupRepo(): string { + const repo = mkdtempSync(join(tmpdir(), "gsd-dispatch-guard-")); + mkdirSync(join(repo, ".gsd"), { recursive: true }); + openDatabase(join(repo, ".gsd", "gsd.db")); + return repo; +} + +/** Helper: tear down repo (close DB then remove dir) */ +function teardownRepo(repo: string): void { + closeDatabase(); + rmSync(repo, { recursive: true, force: true }); +} test("dispatch guard blocks when prior milestone has incomplete slices", () => { - const repo = mkdtempSync(join(tmpdir(), "gsd-dispatch-guard-")); + const repo = setupRepo(); try { mkdirSync(join(repo, ".gsd", "milestones", "M002"), { recursive: true }); mkdirSync(join(repo, ".gsd", "milestones", "M003"), { recursive: true }); - writeFileSync(join(repo, ".gsd", "milestones", "M002", "M002-ROADMAP.md"), - "# M002: Previous\n\n## Slices\n- [x] **S01: Done** `risk:low` `depends:[]`\n- [ ] **S02: Pending** `risk:low` `depends:[S01]`\n"); - writeFileSync(join(repo, ".gsd", "milestones", "M003", "M003-ROADMAP.md"), - "# M003: Current\n\n## Slices\n- [ ] **S01: First** `risk:low` `depends:[]`\n- [ ] **S02: Second** `risk:low` `depends:[S01]`\n"); + // Seed DB: M002 with S01 complete, S02 pending + insertMilestone({ id: "M002", title: "Previous" }); + insertSlice({ id: "S01", milestoneId: "M002", title: "Done", status: "complete", depends: [], sequence: 1 }); + insertSlice({ id: "S02", milestoneId: "M002", title: "Pending", status: "pending", depends: ["S01"], sequence: 2 }); + + // M003 with two pending slices + insertMilestone({ id: "M003", title: "Current" }); + insertSlice({ id: "S01", milestoneId: "M003", title: "First", status: "pending", depends: [], sequence: 1 }); + insertSlice({ id: "S02", milestoneId: "M003", title: "Second", status: "pending", depends: ["S01"], sequence: 2 }); + + // Need ROADMAP files for milestone discovery (findMilestoneIds reads disk) + writeFileSync(join(repo, ".gsd", "milestones", "M002", "M002-ROADMAP.md"), "# M002\n"); + writeFileSync(join(repo, ".gsd", "milestones", "M003", "M003-ROADMAP.md"), "# M003\n"); assert.equal( getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M003/S01"), "Cannot dispatch plan-slice M003/S01: earlier slice M002/S02 is not complete.", ); } finally { - rmSync(repo, { recursive: true, force: true }); + teardownRepo(repo); } }); test("dispatch guard blocks later slice in same milestone when earlier incomplete", () => { - const repo = mkdtempSync(join(tmpdir(), "gsd-dispatch-guard-")); + const repo = setupRepo(); try { mkdirSync(join(repo, ".gsd", "milestones", "M002"), { recursive: true }); mkdirSync(join(repo, ".gsd", "milestones", "M003"), { recursive: true }); - writeFileSync(join(repo, ".gsd", "milestones", "M002", "M002-ROADMAP.md"), - "# M002: Previous\n\n## Slices\n- [x] **S01: Done** `risk:low` `depends:[]`\n- [x] **S02: Done** `risk:low` `depends:[S01]`\n"); - writeFileSync(join(repo, ".gsd", "milestones", "M003", "M003-ROADMAP.md"), - "# M003: Current\n\n## Slices\n- [ ] **S01: First** `risk:low` `depends:[]`\n- [ ] **S02: Second** `risk:low` `depends:[S01]`\n"); + insertMilestone({ id: "M002", title: "Previous" }); + insertSlice({ id: "S01", milestoneId: "M002", title: "Done", status: "complete", depends: [], sequence: 1 }); + insertSlice({ id: "S02", milestoneId: "M002", title: "Done", status: "complete", depends: ["S01"], sequence: 2 }); + + insertMilestone({ id: "M003", title: "Current" }); + insertSlice({ id: "S01", milestoneId: "M003", title: "First", status: "pending", depends: [], sequence: 1 }); + insertSlice({ id: "S02", milestoneId: "M003", title: "Second", status: "pending", depends: ["S01"], sequence: 2 }); + + writeFileSync(join(repo, ".gsd", "milestones", "M002", "M002-ROADMAP.md"), "# M002\n"); + writeFileSync(join(repo, ".gsd", "milestones", "M003", "M003-ROADMAP.md"), "# M003\n"); assert.equal( getPriorSliceCompletionBlocker(repo, "main", "execute-task", "M003/S02/T01"), "Cannot dispatch execute-task M003/S02/T01: dependency slice M003/S01 is not complete.", ); } finally { - rmSync(repo, { recursive: true, force: true }); + teardownRepo(repo); } }); test("dispatch guard allows dispatch when all earlier slices complete", () => { - const repo = mkdtempSync(join(tmpdir(), "gsd-dispatch-guard-")); + const repo = setupRepo(); try { mkdirSync(join(repo, ".gsd", "milestones", "M003"), { recursive: true }); - writeFileSync(join(repo, ".gsd", "milestones", "M003", "M003-ROADMAP.md"), - "# M003: Current\n\n## Slices\n- [x] **S01: First** `risk:low` `depends:[]`\n- [ ] **S02: Second** `risk:low` `depends:[S01]`\n"); + + insertMilestone({ id: "M003", title: "Current" }); + insertSlice({ id: "S01", milestoneId: "M003", title: "First", status: "complete", depends: [], sequence: 1 }); + insertSlice({ id: "S02", milestoneId: "M003", title: "Second", status: "pending", depends: ["S01"], sequence: 2 }); + + writeFileSync(join(repo, ".gsd", "milestones", "M003", "M003-ROADMAP.md"), "# M003\n"); assert.equal(getPriorSliceCompletionBlocker(repo, "main", "execute-task", "M003/S02/T01"), null); assert.equal(getPriorSliceCompletionBlocker(repo, "main", "plan-milestone", "M003"), null); } finally { - rmSync(repo, { recursive: true, force: true }); + teardownRepo(repo); } }); @@ -63,17 +97,19 @@ test("dispatch guard unblocks slice when positionally-earlier slice depends on i // S05 depends on S06, but S05 appears first positionally. // Old behavior: S06 blocked because S05 (positionally earlier) is incomplete. // Fixed behavior: S06 has no unmet dependencies, so it can dispatch. - const repo = mkdtempSync(join(tmpdir(), "gsd-dispatch-guard-")); + const repo = setupRepo(); try { mkdirSync(join(repo, ".gsd", "milestones", "M001"), { recursive: true }); - writeFileSync(join(repo, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), - "# M001: Test\n\n## Slices\n" + - "- [x] **S01: Setup** `risk:low` `depends:[]`\n" + - "- [x] **S02: Core** `risk:low` `depends:[S01]`\n" + - "- [x] **S03: API** `risk:low` `depends:[S02]`\n" + - "- [x] **S04: Auth** `risk:low` `depends:[S03]`\n" + - "- [ ] **S05: Integration** `risk:high` `depends:[S04,S06]`\n" + - "- [ ] **S06: Data Layer** `risk:medium` `depends:[S04]`\n"); + + insertMilestone({ id: "M001", title: "Test" }); + insertSlice({ id: "S01", milestoneId: "M001", title: "Setup", status: "complete", depends: [], sequence: 1 }); + insertSlice({ id: "S02", milestoneId: "M001", title: "Core", status: "complete", depends: ["S01"], sequence: 2 }); + insertSlice({ id: "S03", milestoneId: "M001", title: "API", status: "complete", depends: ["S02"], sequence: 3 }); + insertSlice({ id: "S04", milestoneId: "M001", title: "Auth", status: "complete", depends: ["S03"], sequence: 4 }); + insertSlice({ id: "S05", milestoneId: "M001", title: "Integration", status: "pending", depends: ["S04", "S06"], sequence: 5 }); + insertSlice({ id: "S06", milestoneId: "M001", title: "Data Layer", status: "pending", depends: ["S04"], sequence: 6 }); + + writeFileSync(join(repo, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "# M001\n"); // S06 depends only on S04 (complete) — should be unblocked assert.equal( @@ -87,19 +123,21 @@ test("dispatch guard unblocks slice when positionally-earlier slice depends on i "Cannot dispatch plan-slice M001/S05: dependency slice M001/S06 is not complete.", ); } finally { - rmSync(repo, { recursive: true, force: true }); + teardownRepo(repo); } }); test("dispatch guard falls back to positional ordering when no dependencies declared", () => { - const repo = mkdtempSync(join(tmpdir(), "gsd-dispatch-guard-")); + const repo = setupRepo(); try { mkdirSync(join(repo, ".gsd", "milestones", "M001"), { recursive: true }); - writeFileSync(join(repo, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), - "# M001: Test\n\n## Slices\n" + - "- [x] **S01: First** `risk:low` `depends:[]`\n" + - "- [ ] **S02: Second** `risk:low` `depends:[]`\n" + - "- [ ] **S03: Third** `risk:low` `depends:[]`\n"); + + insertMilestone({ id: "M001", title: "Test" }); + insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "complete", depends: [], sequence: 1 }); + insertSlice({ id: "S02", milestoneId: "M001", title: "Second", status: "pending", depends: [], sequence: 2 }); + insertSlice({ id: "S03", milestoneId: "M001", title: "Third", status: "pending", depends: [], sequence: 3 }); + + writeFileSync(join(repo, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "# M001\n"); // S03 has no dependencies — positional fallback blocks on S02 assert.equal( @@ -113,20 +151,22 @@ test("dispatch guard falls back to positional ordering when no dependencies decl null, ); } finally { - rmSync(repo, { recursive: true, force: true }); + teardownRepo(repo); } }); test("dispatch guard allows slice with all declared dependencies complete", () => { - const repo = mkdtempSync(join(tmpdir(), "gsd-dispatch-guard-")); + const repo = setupRepo(); try { mkdirSync(join(repo, ".gsd", "milestones", "M001"), { recursive: true }); - writeFileSync(join(repo, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), - "# M001: Test\n\n## Slices\n" + - "- [x] **S01: Setup** `risk:low` `depends:[]`\n" + - "- [x] **S02: Core** `risk:low` `depends:[S01]`\n" + - "- [ ] **S03: Feature A** `risk:low` `depends:[S01,S02]`\n" + - "- [ ] **S04: Feature B** `risk:low` `depends:[S01]`\n"); + + insertMilestone({ id: "M001", title: "Test" }); + insertSlice({ id: "S01", milestoneId: "M001", title: "Setup", status: "complete", depends: [], sequence: 1 }); + insertSlice({ id: "S02", milestoneId: "M001", title: "Core", status: "complete", depends: ["S01"], sequence: 2 }); + insertSlice({ id: "S03", milestoneId: "M001", title: "Feature A", status: "pending", depends: ["S01", "S02"], sequence: 3 }); + insertSlice({ id: "S04", milestoneId: "M001", title: "Feature B", status: "pending", depends: ["S01"], sequence: 4 }); + + writeFileSync(join(repo, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "# M001\n"); // S03 depends on S01 (done) and S02 (done) — unblocked assert.equal( @@ -140,28 +180,31 @@ test("dispatch guard allows slice with all declared dependencies complete", () = null, ); } finally { - rmSync(repo, { recursive: true, force: true }); + teardownRepo(repo); } }); test("dispatch guard skips completed milestone with SUMMARY even if it has unchecked remediation slices (#1716)", () => { - const repo = mkdtempSync(join(tmpdir(), "gsd-dispatch-guard-")); + const repo = setupRepo(); try { mkdirSync(join(repo, ".gsd", "milestones", "M001"), { recursive: true }); mkdirSync(join(repo, ".gsd", "milestones", "M002"), { recursive: true }); - // M001 is complete (has SUMMARY) but has unchecked remediation slices - writeFileSync(join(repo, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), - "# M001: Previous\n\n## Slices\n" + - "- [x] **S01: Core** `risk:low` `depends:[]`\n" + - "- [x] **S02: Tests** `risk:low` `depends:[S01]`\n" + - "- [ ] **S03-R: Remediation** `risk:low` `depends:[S02]`\n" + - "- [ ] **S04-R: Remediation 2** `risk:low` `depends:[S02]`\n"); + // M001 is complete (has SUMMARY) but has unchecked remediation slices in DB + insertMilestone({ id: "M001", title: "Previous" }); + insertSlice({ id: "S01", milestoneId: "M001", title: "Core", status: "complete", depends: [], sequence: 1 }); + insertSlice({ id: "S02", milestoneId: "M001", title: "Tests", status: "complete", depends: ["S01"], sequence: 2 }); + insertSlice({ id: "S03-R", milestoneId: "M001", title: "Remediation", status: "pending", depends: ["S02"], sequence: 3 }); + insertSlice({ id: "S04-R", milestoneId: "M001", title: "Remediation 2", status: "pending", depends: ["S02"], sequence: 4 }); + + insertMilestone({ id: "M002", title: "Current" }); + insertSlice({ id: "S01", milestoneId: "M002", title: "Start", status: "pending", depends: [], sequence: 1 }); + + // M001 SUMMARY on disk triggers skip + writeFileSync(join(repo, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "# M001\n"); writeFileSync(join(repo, ".gsd", "milestones", "M001", "M001-SUMMARY.md"), "---\nstatus: complete\n---\n# M001 Summary\nDone.\n"); - - writeFileSync(join(repo, ".gsd", "milestones", "M002", "M002-ROADMAP.md"), - "# M002: Current\n\n## Slices\n- [ ] **S01: Start** `risk:low` `depends:[]`\n"); + writeFileSync(join(repo, ".gsd", "milestones", "M002", "M002-ROADMAP.md"), "# M002\n"); // M001 has SUMMARY — should be skipped, not block M002/S01 assert.equal( @@ -169,19 +212,23 @@ test("dispatch guard skips completed milestone with SUMMARY even if it has unche null, ); } finally { - rmSync(repo, { recursive: true, force: true }); + teardownRepo(repo); } }); test("dispatch guard works without git repo", () => { - const repo = mkdtempSync(join(tmpdir(), "gsd-dispatch-guard-nogit-")); + const repo = setupRepo(); try { mkdirSync(join(repo, ".gsd", "milestones", "M001"), { recursive: true }); - writeFileSync(join(repo, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), - "# M001: Test\n\n## Slices\n- [x] **S01: Done** `risk:low` `depends:[]`\n- [ ] **S02: Pending** `risk:low` `depends:[S01]`\n"); + + insertMilestone({ id: "M001", title: "Test" }); + insertSlice({ id: "S01", milestoneId: "M001", title: "Done", status: "complete", depends: [], sequence: 1 }); + insertSlice({ id: "S02", milestoneId: "M001", title: "Pending", status: "pending", depends: ["S01"], sequence: 2 }); + + writeFileSync(join(repo, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "# M001\n"); assert.equal(getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M001/S02"), null); } finally { - rmSync(repo, { recursive: true, force: true }); + teardownRepo(repo); } });