refactor: migrate D-G test files from createTestContext to node:test (#2418)

This commit is contained in:
Tom Boucher 2026-03-24 23:34:52 -04:00 committed by GitHub
parent e4d21c40d0
commit b24594d79f
23 changed files with 2928 additions and 3479 deletions

View file

@ -1,3 +1,5 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
/**
* Tests for dashboard budget indicator rendering.
*
@ -18,10 +20,6 @@ import {
getProjectTotals,
formatTokenCount,
} from "../metrics.js";
import { createTestContext } from './test-helpers.ts';
const { assertEq, assertTrue, assertMatch, assertNoMatch, report } = createTestContext();
// ─── Test helpers ─────────────────────────────────────────────────────────────
function makeUnit(overrides: Partial<UnitMetrics> = {}): UnitMetrics {
@ -102,245 +100,230 @@ function renderModelContextWindow(units: UnitMetrics[], modelName: string): stri
// ─── Completed section: budget indicators ─────────────────────────────────────
console.log("\n=== Completed section: truncation + continue-here markers ===");
describe('dashboard-budget', () => {
test('Completed section: truncation + continue-here markers', () => {
// Unit with truncation and continue-here — both markers appear
const ledgerUnits = [
makeUnit({ type: "execute-task", id: "M001/S01/T01", truncationSections: 3, continueHereFired: true }),
];
const markers = renderCompletedBudgetMarkers(
{ type: "execute-task", id: "M001/S01/T01" },
ledgerUnits,
);
assert.match(markers, /▼3/, "completed: shows ▼3 for 3 truncation sections");
assert.match(markers, /→ wrap-up/, "completed: shows → wrap-up when continueHereFired");
});
{
// Unit with truncation and continue-here — both markers appear
const ledgerUnits = [
makeUnit({ type: "execute-task", id: "M001/S01/T01", truncationSections: 3, continueHereFired: true }),
];
const markers = renderCompletedBudgetMarkers(
{ type: "execute-task", id: "M001/S01/T01" },
ledgerUnits,
);
assertMatch(markers, /▼3/, "completed: shows ▼3 for 3 truncation sections");
assertMatch(markers, /→ wrap-up/, "completed: shows → wrap-up when continueHereFired");
}
{
// Unit with truncation only — no wrap-up marker
const ledgerUnits = [
makeUnit({ type: "execute-task", id: "M001/S01/T01", truncationSections: 5, continueHereFired: false }),
];
const markers = renderCompletedBudgetMarkers(
{ type: "execute-task", id: "M001/S01/T01" },
ledgerUnits,
);
assert.match(markers, /▼5/, "completed: shows ▼5 truncation only");
assert.doesNotMatch(markers, /wrap-up/, "completed: no wrap-up when continueHereFired=false");
}
{
// Unit with truncation only — no wrap-up marker
const ledgerUnits = [
makeUnit({ type: "execute-task", id: "M001/S01/T01", truncationSections: 5, continueHereFired: false }),
];
const markers = renderCompletedBudgetMarkers(
{ type: "execute-task", id: "M001/S01/T01" },
ledgerUnits,
);
assertMatch(markers, /▼5/, "completed: shows ▼5 truncation only");
assertNoMatch(markers, /wrap-up/, "completed: no wrap-up when continueHereFired=false");
}
{
// Unit with continue-here only — no truncation marker
const ledgerUnits = [
makeUnit({ type: "execute-task", id: "M001/S01/T01", truncationSections: 0, continueHereFired: true }),
];
const markers = renderCompletedBudgetMarkers(
{ type: "execute-task", id: "M001/S01/T01" },
ledgerUnits,
);
assert.doesNotMatch(markers, /▼/, "completed: no ▼ when truncationSections=0");
assert.match(markers, /→ wrap-up/, "completed: shows → wrap-up");
}
{
// Unit with continue-here only — no truncation marker
const ledgerUnits = [
makeUnit({ type: "execute-task", id: "M001/S01/T01", truncationSections: 0, continueHereFired: true }),
];
const markers = renderCompletedBudgetMarkers(
{ type: "execute-task", id: "M001/S01/T01" },
ledgerUnits,
);
assertNoMatch(markers, /▼/, "completed: no ▼ when truncationSections=0");
assertMatch(markers, /→ wrap-up/, "completed: shows → wrap-up");
}
// ─── Completed section: missing ledger match ──────────────────────────────────
// ─── Completed section: missing ledger match ──────────────────────────────────
test('Completed section: missing ledger match', () => {
// Completed unit with no matching ledger entry — no crash, no markers
const ledgerUnits = [
makeUnit({ type: "execute-task", id: "M001/S01/T99", truncationSections: 3 }),
];
const markers = renderCompletedBudgetMarkers(
{ type: "execute-task", id: "M001/S01/T01" },
ledgerUnits,
);
assert.deepStrictEqual(markers, "", "missing match: empty markers when no ledger entry matches");
});
console.log("\n=== Completed section: missing ledger match ===");
{
// Empty ledger — no crash, no markers
const markers = renderCompletedBudgetMarkers(
{ type: "execute-task", id: "M001/S01/T01" },
[],
);
assert.deepStrictEqual(markers, "", "empty ledger: empty markers");
}
{
// Completed unit with no matching ledger entry — no crash, no markers
const ledgerUnits = [
makeUnit({ type: "execute-task", id: "M001/S01/T99", truncationSections: 3 }),
];
const markers = renderCompletedBudgetMarkers(
{ type: "execute-task", id: "M001/S01/T01" },
ledgerUnits,
);
assertEq(markers, "", "missing match: empty markers when no ledger entry matches");
}
// ─── Completed section: retry handling (last entry wins) ──────────────────────
{
// Empty ledger — no crash, no markers
const markers = renderCompletedBudgetMarkers(
{ type: "execute-task", id: "M001/S01/T01" },
[],
);
assertEq(markers, "", "empty ledger: empty markers");
}
test('Completed section: retry handling', () => {
// Two ledger entries for same unit (retry) — last entry wins
const ledgerUnits = [
makeUnit({ type: "execute-task", id: "M001/S01/T01", truncationSections: 1 }),
makeUnit({ type: "execute-task", id: "M001/S01/T01", truncationSections: 7 }),
];
const markers = renderCompletedBudgetMarkers(
{ type: "execute-task", id: "M001/S01/T01" },
ledgerUnits,
);
assert.match(markers, /▼7/, "retry: last entry's truncation count (7) wins over first (1)");
assert.doesNotMatch(markers, /▼1/, "retry: first entry's count (1) is not shown");
});
// ─── Completed section: retry handling (last entry wins) ──────────────────────
// ─── By Model section: context window display ─────────────────────────────────
console.log("\n=== Completed section: retry handling ===");
test('By Model section: context window', () => {
// Model with context window — shows formatted token count
const units = [
makeUnit({ model: "claude-sonnet-4-20250514", contextWindowTokens: 200000 }),
];
const label = renderModelContextWindow(units, "claude-sonnet-4-20250514");
assert.deepStrictEqual(label, "[200.0k]", "by model: shows [200.0k] for 200000 context window");
});
{
// Two ledger entries for same unit (retry) — last entry wins
const ledgerUnits = [
makeUnit({ type: "execute-task", id: "M001/S01/T01", truncationSections: 1 }),
makeUnit({ type: "execute-task", id: "M001/S01/T01", truncationSections: 7 }),
];
const markers = renderCompletedBudgetMarkers(
{ type: "execute-task", id: "M001/S01/T01" },
ledgerUnits,
);
assertMatch(markers, /▼7/, "retry: last entry's truncation count (7) wins over first (1)");
assertNoMatch(markers, /▼1/, "retry: first entry's count (1) is not shown");
}
{
// Model without context window — no label
const units = [
makeUnit({ model: "claude-sonnet-4-20250514" }),
];
const label = renderModelContextWindow(units, "claude-sonnet-4-20250514");
assert.deepStrictEqual(label, null, "by model: null when no contextWindowTokens");
}
// ─── By Model section: context window display ─────────────────────────────────
{
// Multiple models — each gets its own context window
const units = [
makeUnit({ model: "claude-sonnet-4-20250514", contextWindowTokens: 200000, cost: 0.05 }),
makeUnit({ model: "claude-opus-4-20250514", contextWindowTokens: 200000, cost: 0.30 }),
];
const sonnetLabel = renderModelContextWindow(units, "claude-sonnet-4-20250514");
const opusLabel = renderModelContextWindow(units, "claude-opus-4-20250514");
assert.deepStrictEqual(sonnetLabel, "[200.0k]", "by model multi: sonnet has context window");
assert.deepStrictEqual(opusLabel, "[200.0k]", "by model multi: opus has context window");
}
console.log("\n=== By Model section: context window ===");
// ─── By Model section: single model visibility ───────────────────────────────
{
// Model with context window — shows formatted token count
const units = [
makeUnit({ model: "claude-sonnet-4-20250514", contextWindowTokens: 200000 }),
];
const label = renderModelContextWindow(units, "claude-sonnet-4-20250514");
assertEq(label, "[200.0k]", "by model: shows [200.0k] for 200000 context window");
}
test('By Model section: single model visibility', () => {
// With guard changed to >= 1, single model aggregation should produce results
const units = [
makeUnit({ model: "claude-sonnet-4-20250514" }),
];
const models = aggregateByModel(units);
assert.ok(models.length >= 1, "single model: aggregateByModel returns >= 1 entry");
assert.deepStrictEqual(models.length, 1, "single model: exactly 1 model aggregate");
assert.deepStrictEqual(models[0].model, "claude-sonnet-4-20250514", "single model: correct model name");
// The guard `models.length >= 1` (changed from > 1) means this section now renders
assert.ok(models.length >= 1, "single model: passes >= 1 guard (section will render)");
});
{
// Model without context window — no label
const units = [
makeUnit({ model: "claude-sonnet-4-20250514" }),
];
const label = renderModelContextWindow(units, "claude-sonnet-4-20250514");
assertEq(label, null, "by model: null when no contextWindowTokens");
}
// ─── Cost & Usage: aggregate budget line ──────────────────────────────────────
{
// Multiple models — each gets its own context window
const units = [
makeUnit({ model: "claude-sonnet-4-20250514", contextWindowTokens: 200000, cost: 0.05 }),
makeUnit({ model: "claude-opus-4-20250514", contextWindowTokens: 200000, cost: 0.30 }),
];
const sonnetLabel = renderModelContextWindow(units, "claude-sonnet-4-20250514");
const opusLabel = renderModelContextWindow(units, "claude-opus-4-20250514");
assertEq(sonnetLabel, "[200.0k]", "by model multi: sonnet has context window");
assertEq(opusLabel, "[200.0k]", "by model multi: opus has context window");
}
test('Cost & Usage: aggregate budget line', () => {
// Units with truncation and continue-here — both stats appear
const units = [
makeUnit({ truncationSections: 3, continueHereFired: true }),
makeUnit({ truncationSections: 2, continueHereFired: false }),
makeUnit({ truncationSections: 1, continueHereFired: true }),
];
const line = renderCostBudgetLine(units);
assert.ok(line !== null, "cost budget: line rendered when budget data exists");
assert.match(line!, /6 sections truncated/, "cost budget: shows total truncation count (3+2+1=6)");
assert.match(line!, /2 continue-here fired/, "cost budget: shows continue-here count");
});
// ─── By Model section: single model visibility ───────────────────────────────
{
// Only truncation, no continue-here
const units = [
makeUnit({ truncationSections: 4, continueHereFired: false }),
];
const line = renderCostBudgetLine(units);
assert.ok(line !== null, "cost budget truncation-only: line rendered");
assert.match(line!, /4 sections truncated/, "cost budget truncation-only: shows count");
assert.doesNotMatch(line!, /continue-here/, "cost budget truncation-only: no continue-here text");
}
console.log("\n=== By Model section: single model visibility ===");
{
// Only continue-here, no truncation
const units = [
makeUnit({ truncationSections: 0, continueHereFired: true }),
];
const line = renderCostBudgetLine(units);
assert.ok(line !== null, "cost budget continue-only: line rendered");
assert.doesNotMatch(line!, /truncated/, "cost budget continue-only: no truncation text");
assert.match(line!, /1 continue-here fired/, "cost budget continue-only: shows count");
}
{
// With guard changed to >= 1, single model aggregation should produce results
const units = [
makeUnit({ model: "claude-sonnet-4-20250514" }),
];
const models = aggregateByModel(units);
assertTrue(models.length >= 1, "single model: aggregateByModel returns >= 1 entry");
assertEq(models.length, 1, "single model: exactly 1 model aggregate");
assertEq(models[0].model, "claude-sonnet-4-20250514", "single model: correct model name");
// The guard `models.length >= 1` (changed from > 1) means this section now renders
assertTrue(models.length >= 1, "single model: passes >= 1 guard (section will render)");
}
// ─── Backward compat: no budget fields ────────────────────────────────────────
// ─── Cost & Usage: aggregate budget line ──────────────────────────────────────
test('Backward compat: no budget data', () => {
// Old-format units without budget fields — no indicators anywhere
const oldUnits = [
makeUnit(), // no budget fields
makeUnit({ id: "M001/S01/T02" }),
];
console.log("\n=== Cost & Usage: aggregate budget line ===");
// Completed section: no markers
const markers = renderCompletedBudgetMarkers(
{ type: "execute-task", id: "M001/S01/T01" },
oldUnits,
);
assert.doesNotMatch(markers, /▼/, "backward compat completed: no truncation marker");
assert.doesNotMatch(markers, /wrap-up/, "backward compat completed: no wrap-up marker");
assert.deepStrictEqual(markers, "", "backward compat completed: empty markers string");
{
// Units with truncation and continue-here — both stats appear
const units = [
makeUnit({ truncationSections: 3, continueHereFired: true }),
makeUnit({ truncationSections: 2, continueHereFired: false }),
makeUnit({ truncationSections: 1, continueHereFired: true }),
];
const line = renderCostBudgetLine(units);
assertTrue(line !== null, "cost budget: line rendered when budget data exists");
assertMatch(line!, /6 sections truncated/, "cost budget: shows total truncation count (3+2+1=6)");
assertMatch(line!, /2 continue-here fired/, "cost budget: shows continue-here count");
}
// By Model section: no context window label
const label = renderModelContextWindow(oldUnits, "claude-sonnet-4-20250514");
assert.deepStrictEqual(label, null, "backward compat by-model: no context window label");
{
// Only truncation, no continue-here
const units = [
makeUnit({ truncationSections: 4, continueHereFired: false }),
];
const line = renderCostBudgetLine(units);
assertTrue(line !== null, "cost budget truncation-only: line rendered");
assertMatch(line!, /4 sections truncated/, "cost budget truncation-only: shows count");
assertNoMatch(line!, /continue-here/, "cost budget truncation-only: no continue-here text");
}
// Cost & Usage: no budget line
const line = renderCostBudgetLine(oldUnits);
assert.deepStrictEqual(line, null, "backward compat cost: no budget summary line");
{
// Only continue-here, no truncation
const units = [
makeUnit({ truncationSections: 0, continueHereFired: true }),
];
const line = renderCostBudgetLine(units);
assertTrue(line !== null, "cost budget continue-only: line rendered");
assertNoMatch(line!, /truncated/, "cost budget continue-only: no truncation text");
assertMatch(line!, /1 continue-here fired/, "cost budget continue-only: shows count");
}
// Aggregation still works
const totals = getProjectTotals(oldUnits);
assert.deepStrictEqual(totals.totalTruncationSections, 0, "backward compat: truncation total = 0");
assert.deepStrictEqual(totals.continueHereFiredCount, 0, "backward compat: continueHere count = 0");
assert.deepStrictEqual(totals.units, 2, "backward compat: unit count correct");
});
// ─── Backward compat: no budget fields ────────────────────────────────────────
// ─── Edge cases ───────────────────────────────────────────────────────────────
console.log("\n=== Backward compat: no budget data ===");
test('Edge cases', () => {
// formatTokenCount for context window values
assert.deepStrictEqual(formatTokenCount(200000), "200.0k", "format: 200000 → 200.0k");
assert.deepStrictEqual(formatTokenCount(128000), "128.0k", "format: 128000 → 128.0k");
assert.deepStrictEqual(formatTokenCount(1000000), "1.00M", "format: 1000000 → 1.00M");
assert.deepStrictEqual(formatTokenCount(32000), "32.0k", "format: 32000 → 32.0k");
});
{
// Old-format units without budget fields — no indicators anywhere
const oldUnits = [
makeUnit(), // no budget fields
makeUnit({ id: "M001/S01/T02" }),
];
{
// Completed unit key includes type — different types don't collide
const ledgerUnits = [
makeUnit({ type: "research-slice", id: "M001/S01", truncationSections: 2 }),
makeUnit({ type: "plan-slice", id: "M001/S01", truncationSections: 5 }),
];
const researchMarkers = renderCompletedBudgetMarkers(
{ type: "research-slice", id: "M001/S01" },
ledgerUnits,
);
const planMarkers = renderCompletedBudgetMarkers(
{ type: "plan-slice", id: "M001/S01" },
ledgerUnits,
);
assert.match(researchMarkers, /▼2/, "type-keying: research unit gets its own truncation count");
assert.match(planMarkers, /▼5/, "type-keying: plan unit gets its own truncation count");
}
// Completed section: no markers
const markers = renderCompletedBudgetMarkers(
{ type: "execute-task", id: "M001/S01/T01" },
oldUnits,
);
assertNoMatch(markers, /▼/, "backward compat completed: no truncation marker");
assertNoMatch(markers, /wrap-up/, "backward compat completed: no wrap-up marker");
assertEq(markers, "", "backward compat completed: empty markers string");
// ─── Summary ──────────────────────────────────────────────────────────────────
// By Model section: no context window label
const label = renderModelContextWindow(oldUnits, "claude-sonnet-4-20250514");
assertEq(label, null, "backward compat by-model: no context window label");
// Cost & Usage: no budget line
const line = renderCostBudgetLine(oldUnits);
assertEq(line, null, "backward compat cost: no budget summary line");
// Aggregation still works
const totals = getProjectTotals(oldUnits);
assertEq(totals.totalTruncationSections, 0, "backward compat: truncation total = 0");
assertEq(totals.continueHereFiredCount, 0, "backward compat: continueHere count = 0");
assertEq(totals.units, 2, "backward compat: unit count correct");
}
// ─── Edge cases ───────────────────────────────────────────────────────────────
console.log("\n=== Edge cases ===");
{
// formatTokenCount for context window values
assertEq(formatTokenCount(200000), "200.0k", "format: 200000 → 200.0k");
assertEq(formatTokenCount(128000), "128.0k", "format: 128000 → 128.0k");
assertEq(formatTokenCount(1000000), "1.00M", "format: 1000000 → 1.00M");
assertEq(formatTokenCount(32000), "32.0k", "format: 32000 → 32.0k");
}
{
// Completed unit key includes type — different types don't collide
const ledgerUnits = [
makeUnit({ type: "research-slice", id: "M001/S01", truncationSections: 2 }),
makeUnit({ type: "plan-slice", id: "M001/S01", truncationSections: 5 }),
];
const researchMarkers = renderCompletedBudgetMarkers(
{ type: "research-slice", id: "M001/S01" },
ledgerUnits,
);
const planMarkers = renderCompletedBudgetMarkers(
{ type: "plan-slice", id: "M001/S01" },
ledgerUnits,
);
assertMatch(researchMarkers, /▼2/, "type-keying: research unit gets its own truncation count");
assertMatch(planMarkers, /▼5/, "type-keying: plan unit gets its own truncation count");
}
// ─── Summary ──────────────────────────────────────────────────────────────────
report();
});

View file

@ -1,4 +1,5 @@
import { createTestContext } from './test-helpers.ts';
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import * as path from 'node:path';
import * as os from 'node:os';
import * as fs from 'node:fs';
@ -26,8 +27,6 @@ import {
} from '../db-writer.ts';
import type { Decision, Requirement } from '../types.ts';
const { assertEq, assertTrue, assertMatch, report } = createTestContext();
// ═══════════════════════════════════════════════════════════════════════════
// Helpers
// ═══════════════════════════════════════════════════════════════════════════
@ -151,462 +150,433 @@ const SAMPLE_REQUIREMENTS: Requirement[] = [
// Round-Trip Tests: Decisions
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n── generateDecisionsMd round-trip ──');
describe('db-writer', () => {
test('generateDecisionsMd round-trip', () => {
const md = generateDecisionsMd(SAMPLE_DECISIONS);
const parsed = parseDecisionsTable(md);
{
const md = generateDecisionsMd(SAMPLE_DECISIONS);
const parsed = parseDecisionsTable(md);
assert.deepStrictEqual(parsed.length, SAMPLE_DECISIONS.length, 'decisions count matches');
assertEq(parsed.length, SAMPLE_DECISIONS.length, 'decisions count matches');
for (let i = 0; i < SAMPLE_DECISIONS.length; i++) {
const orig = SAMPLE_DECISIONS[i];
const rt = parsed[i];
assert.deepStrictEqual(rt.id, orig.id, `decision ${orig.id} id round-trips`);
assert.deepStrictEqual(rt.when_context, orig.when_context, `decision ${orig.id} when_context round-trips`);
assert.deepStrictEqual(rt.scope, orig.scope, `decision ${orig.id} scope round-trips`);
assert.deepStrictEqual(rt.decision, orig.decision, `decision ${orig.id} decision round-trips`);
assert.deepStrictEqual(rt.choice, orig.choice, `decision ${orig.id} choice round-trips`);
assert.deepStrictEqual(rt.rationale, orig.rationale, `decision ${orig.id} rationale round-trips`);
assert.deepStrictEqual(rt.revisable, orig.revisable, `decision ${orig.id} revisable round-trips`);
assert.deepStrictEqual(rt.made_by, orig.made_by, `decision ${orig.id} made_by round-trips`);
}
});
for (let i = 0; i < SAMPLE_DECISIONS.length; i++) {
const orig = SAMPLE_DECISIONS[i];
const rt = parsed[i];
assertEq(rt.id, orig.id, `decision ${orig.id} id round-trips`);
assertEq(rt.when_context, orig.when_context, `decision ${orig.id} when_context round-trips`);
assertEq(rt.scope, orig.scope, `decision ${orig.id} scope round-trips`);
assertEq(rt.decision, orig.decision, `decision ${orig.id} decision round-trips`);
assertEq(rt.choice, orig.choice, `decision ${orig.id} choice round-trips`);
assertEq(rt.rationale, orig.rationale, `decision ${orig.id} rationale round-trips`);
assertEq(rt.revisable, orig.revisable, `decision ${orig.id} revisable round-trips`);
assertEq(rt.made_by, orig.made_by, `decision ${orig.id} made_by round-trips`);
}
}
test('generateDecisionsMd format', () => {
const md = generateDecisionsMd(SAMPLE_DECISIONS);
assert.ok(md.startsWith('# Decisions Register\n'), 'starts with H1 header');
assert.ok(md.includes('<!-- Append-only'), 'contains HTML comment block');
assert.ok(md.includes('| # | When | Scope'), 'contains table header');
assert.ok(md.includes('|---|------|-------'), 'contains separator row');
assert.ok(md.includes('| Made By |'), 'contains Made By column header');
});
console.log('\n── generateDecisionsMd format ──');
test('generateDecisionsMd empty input', () => {
const md = generateDecisionsMd([]);
const parsed = parseDecisionsTable(md);
assert.deepStrictEqual(parsed.length, 0, 'empty decisions produces empty parse');
assert.ok(md.includes('| # | When | Scope'), 'still has table header even when empty');
});
{
const md = generateDecisionsMd(SAMPLE_DECISIONS);
assertTrue(md.startsWith('# Decisions Register\n'), 'starts with H1 header');
assertTrue(md.includes('<!-- Append-only'), 'contains HTML comment block');
assertTrue(md.includes('| # | When | Scope'), 'contains table header');
assertTrue(md.includes('|---|------|-------'), 'contains separator row');
assertTrue(md.includes('| Made By |'), 'contains Made By column header');
}
test('generateDecisionsMd pipe escaping', () => {
const withPipe: Decision = {
seq: 1,
id: 'D001',
when_context: 'M001',
scope: 'arch',
decision: 'Choice A | Choice B comparison',
choice: 'A',
rationale: 'Better',
revisable: 'No',
made_by: 'agent',
superseded_by: null,
};
const md = generateDecisionsMd([withPipe]);
// Should not break the table — pipe in decision text should be escaped
const parsed = parseDecisionsTable(md);
assert.ok(parsed.length >= 1, 'pipe-containing decision parses without breaking table');
});
console.log('\n── generateDecisionsMd empty input ──');
// ═══════════════════════════════════════════════════════════════════════════
// Round-Trip Tests: Requirements
// ═══════════════════════════════════════════════════════════════════════════
{
const md = generateDecisionsMd([]);
const parsed = parseDecisionsTable(md);
assertEq(parsed.length, 0, 'empty decisions produces empty parse');
assertTrue(md.includes('| # | When | Scope'), 'still has table header even when empty');
}
test('generateRequirementsMd round-trip', () => {
const md = generateRequirementsMd(SAMPLE_REQUIREMENTS);
const parsed = parseRequirementsSections(md);
console.log('\n── generateDecisionsMd pipe escaping ──');
assert.deepStrictEqual(parsed.length, SAMPLE_REQUIREMENTS.length, 'requirements count matches');
{
const withPipe: Decision = {
seq: 1,
id: 'D001',
when_context: 'M001',
scope: 'arch',
decision: 'Choice A | Choice B comparison',
choice: 'A',
rationale: 'Better',
revisable: 'No',
made_by: 'agent',
superseded_by: null,
};
const md = generateDecisionsMd([withPipe]);
// Should not break the table — pipe in decision text should be escaped
const parsed = parseDecisionsTable(md);
assertTrue(parsed.length >= 1, 'pipe-containing decision parses without breaking table');
}
// ═══════════════════════════════════════════════════════════════════════════
// Round-Trip Tests: Requirements
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n── generateRequirementsMd round-trip ──');
{
const md = generateRequirementsMd(SAMPLE_REQUIREMENTS);
const parsed = parseRequirementsSections(md);
assertEq(parsed.length, SAMPLE_REQUIREMENTS.length, 'requirements count matches');
for (const orig of SAMPLE_REQUIREMENTS) {
const rt = parsed.find(r => r.id === orig.id);
assertTrue(!!rt, `requirement ${orig.id} found in parsed output`);
if (rt) {
assertEq(rt.class, orig.class, `requirement ${orig.id} class round-trips`);
assertEq(rt.description, orig.description, `requirement ${orig.id} description round-trips`);
assertEq(rt.why, orig.why, `requirement ${orig.id} why round-trips`);
assertEq(rt.source, orig.source, `requirement ${orig.id} source round-trips`);
assertEq(rt.primary_owner, orig.primary_owner, `requirement ${orig.id} primary_owner round-trips`);
assertEq(rt.supporting_slices, orig.supporting_slices, `requirement ${orig.id} supporting_slices round-trips`);
if (orig.notes) {
assertEq(rt.notes, orig.notes, `requirement ${orig.id} notes round-trips`);
for (const orig of SAMPLE_REQUIREMENTS) {
const rt = parsed.find(r => r.id === orig.id);
assert.ok(!!rt, `requirement ${orig.id} found in parsed output`);
if (rt) {
assert.deepStrictEqual(rt.class, orig.class, `requirement ${orig.id} class round-trips`);
assert.deepStrictEqual(rt.description, orig.description, `requirement ${orig.id} description round-trips`);
assert.deepStrictEqual(rt.why, orig.why, `requirement ${orig.id} why round-trips`);
assert.deepStrictEqual(rt.source, orig.source, `requirement ${orig.id} source round-trips`);
assert.deepStrictEqual(rt.primary_owner, orig.primary_owner, `requirement ${orig.id} primary_owner round-trips`);
assert.deepStrictEqual(rt.supporting_slices, orig.supporting_slices, `requirement ${orig.id} supporting_slices round-trips`);
if (orig.notes) {
assert.deepStrictEqual(rt.notes, orig.notes, `requirement ${orig.id} notes round-trips`);
}
}
}
}
}
console.log('\n── generateRequirementsMd sections ──');
{
const md = generateRequirementsMd(SAMPLE_REQUIREMENTS);
assertTrue(md.includes('## Active'), 'has Active section');
assertTrue(md.includes('## Validated'), 'has Validated section');
assertTrue(md.includes('## Deferred'), 'has Deferred section');
assertTrue(md.includes('## Out of Scope'), 'has Out of Scope section');
assertTrue(md.includes('## Traceability'), 'has Traceability section');
assertTrue(md.includes('## Coverage Summary'), 'has Coverage Summary section');
}
console.log('\n── generateRequirementsMd only populated sections ──');
{
// Only active requirements — should only have Active section
const activeOnly = SAMPLE_REQUIREMENTS.filter(r => r.status === 'active');
const md = generateRequirementsMd(activeOnly);
assertTrue(md.includes('## Active'), 'has Active section');
assertTrue(!md.includes('## Validated'), 'no Validated section when no validated reqs');
assertTrue(!md.includes('## Deferred'), 'no Deferred section when no deferred reqs');
assertTrue(!md.includes('## Out of Scope'), 'no Out of Scope section when no out-of-scope reqs');
}
console.log('\n── generateRequirementsMd empty input ──');
{
const md = generateRequirementsMd([]);
const parsed = parseRequirementsSections(md);
assertEq(parsed.length, 0, 'empty requirements produces empty parse');
}
// ═══════════════════════════════════════════════════════════════════════════
// nextDecisionId Tests
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n── nextDecisionId ──');
{
// Open in-memory DB
openDatabase(':memory:');
const id1 = await nextDecisionId();
assertEq(id1, 'D001', 'first ID when no decisions exist');
// Insert some decisions
upsertDecision({
id: 'D001',
when_context: 'M001',
scope: 'test',
decision: 'test decision',
choice: 'test choice',
rationale: 'test',
revisable: 'No',
made_by: 'agent',
superseded_by: null,
});
upsertDecision({
id: 'D005',
when_context: 'M001',
scope: 'test',
decision: 'test decision 5',
choice: 'test choice',
rationale: 'test',
revisable: 'No',
made_by: 'agent',
superseded_by: null,
});
const id2 = await nextDecisionId();
assertEq(id2, 'D006', 'next ID after D005 is D006');
test('generateRequirementsMd sections', () => {
const md = generateRequirementsMd(SAMPLE_REQUIREMENTS);
assert.ok(md.includes('## Active'), 'has Active section');
assert.ok(md.includes('## Validated'), 'has Validated section');
assert.ok(md.includes('## Deferred'), 'has Deferred section');
assert.ok(md.includes('## Out of Scope'), 'has Out of Scope section');
assert.ok(md.includes('## Traceability'), 'has Traceability section');
assert.ok(md.includes('## Coverage Summary'), 'has Coverage Summary section');
});
closeDatabase();
}
test('generateRequirementsMd only populated sections', () => {
// Only active requirements — should only have Active section
const activeOnly = SAMPLE_REQUIREMENTS.filter(r => r.status === 'active');
const md = generateRequirementsMd(activeOnly);
assert.ok(md.includes('## Active'), 'has Active section');
assert.ok(!md.includes('## Validated'), 'no Validated section when no validated reqs');
assert.ok(!md.includes('## Deferred'), 'no Deferred section when no deferred reqs');
assert.ok(!md.includes('## Out of Scope'), 'no Out of Scope section when no out-of-scope reqs');
});
// ═══════════════════════════════════════════════════════════════════════════
// saveDecisionToDb Tests
// ═══════════════════════════════════════════════════════════════════════════
test('generateRequirementsMd empty input', () => {
const md = generateRequirementsMd([]);
const parsed = parseRequirementsSections(md);
assert.deepStrictEqual(parsed.length, 0, 'empty requirements produces empty parse');
});
console.log('\n── saveDecisionToDb ──');
// ═══════════════════════════════════════════════════════════════════════════
// nextDecisionId Tests
// ═══════════════════════════════════════════════════════════════════════════
{
const tmpDir = makeTmpDir();
const dbPath = path.join(tmpDir, '.gsd', 'gsd.db');
openDatabase(dbPath);
test('nextDecisionId', async () => {
// Open in-memory DB
openDatabase(':memory:');
try {
const result = await saveDecisionToDb({
scope: 'arch',
decision: 'Test decision',
choice: 'Option A',
rationale: 'Best option',
const id1 = await nextDecisionId();
assert.deepStrictEqual(id1, 'D001', 'first ID when no decisions exist');
// Insert some decisions
upsertDecision({
id: 'D001',
when_context: 'M001',
}, tmpDir);
assertEq(result.id, 'D001', 'saveDecisionToDb returns D001 as first ID');
// Verify DB state
const dbDecision = getDecisionById('D001');
assertTrue(!!dbDecision, 'decision exists in DB after save');
assertEq(dbDecision?.scope, 'arch', 'DB decision has correct scope');
assertEq(dbDecision?.choice, 'Option A', 'DB decision has correct choice');
// Verify markdown file was written
const mdPath = path.join(tmpDir, '.gsd', 'DECISIONS.md');
assertTrue(fs.existsSync(mdPath), 'DECISIONS.md file created');
const mdContent = fs.readFileSync(mdPath, 'utf-8');
assertTrue(mdContent.includes('D001'), 'DECISIONS.md contains new decision ID');
assertTrue(mdContent.includes('Test decision'), 'DECISIONS.md contains decision text');
// Verify round-trip of the written file
const parsed = parseDecisionsTable(mdContent);
assertEq(parsed.length, 1, 'written DECISIONS.md parses to 1 decision');
assertEq(parsed[0].id, 'D001', 'parsed decision has correct ID');
// Add second decision
const result2 = await saveDecisionToDb({
scope: 'impl',
decision: 'Second decision',
choice: 'Option B',
rationale: 'Also good',
}, tmpDir);
assertEq(result2.id, 'D002', 'second decision gets D002');
const mdContent2 = fs.readFileSync(mdPath, 'utf-8');
const parsed2 = parseDecisionsTable(mdContent2);
assertEq(parsed2.length, 2, 'DECISIONS.md now has 2 decisions');
} finally {
closeDatabase();
cleanupDir(tmpDir);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// updateRequirementInDb Tests
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n── updateRequirementInDb ──');
{
const tmpDir = makeTmpDir();
const dbPath = path.join(tmpDir, '.gsd', 'gsd.db');
openDatabase(dbPath);
try {
// Seed a requirement
upsertRequirement({
id: 'R001',
class: 'core-capability',
status: 'active',
description: 'Test requirement',
why: 'Testing',
source: 'test',
primary_owner: 'M001/S01',
supporting_slices: 'none',
validation: 'unmapped',
notes: '',
full_content: '',
scope: 'test',
decision: 'test decision',
choice: 'test choice',
rationale: 'test',
revisable: 'No',
made_by: 'agent',
superseded_by: null,
});
upsertDecision({
id: 'D005',
when_context: 'M001',
scope: 'test',
decision: 'test decision 5',
choice: 'test choice',
rationale: 'test',
revisable: 'No',
made_by: 'agent',
superseded_by: null,
});
// Update it
await updateRequirementInDb('R001', {
status: 'validated',
validation: 'S01 — all tests pass',
notes: 'Validated in S01',
}, tmpDir);
const id2 = await nextDecisionId();
assert.deepStrictEqual(id2, 'D006', 'next ID after D005 is D006');
// Verify DB state
const updated = getRequirementById('R001');
assertTrue(!!updated, 'requirement still exists after update');
assertEq(updated?.status, 'validated', 'status updated in DB');
assertEq(updated?.validation, 'S01 — all tests pass', 'validation updated in DB');
assertEq(updated?.description, 'Test requirement', 'description preserved after update');
// Verify markdown file was written
const mdPath = path.join(tmpDir, '.gsd', 'REQUIREMENTS.md');
assertTrue(fs.existsSync(mdPath), 'REQUIREMENTS.md file created');
const mdContent = fs.readFileSync(mdPath, 'utf-8');
assertTrue(mdContent.includes('R001'), 'REQUIREMENTS.md contains requirement ID');
assertTrue(mdContent.includes('validated'), 'REQUIREMENTS.md shows updated status');
// Verify round-trip
const parsed = parseRequirementsSections(mdContent);
assertEq(parsed.length, 1, 'parsed 1 requirement from written file');
assertEq(parsed[0].status, 'validated', 'parsed status matches update');
} finally {
closeDatabase();
cleanupDir(tmpDir);
}
}
});
console.log('\n── updateRequirementInDb — not found ──');
// ═══════════════════════════════════════════════════════════════════════════
// saveDecisionToDb Tests
// ═══════════════════════════════════════════════════════════════════════════
{
const tmpDir = makeTmpDir();
const dbPath = path.join(tmpDir, '.gsd', 'gsd.db');
openDatabase(dbPath);
test('saveDecisionToDb', async () => {
const tmpDir = makeTmpDir();
const dbPath = path.join(tmpDir, '.gsd', 'gsd.db');
openDatabase(dbPath);
try {
let threw = false;
try {
await updateRequirementInDb('R999', { status: 'validated' }, tmpDir);
} catch (err) {
threw = true;
assertTrue(
(err as Error).message.includes('R999'),
'error message mentions the missing ID',
const result = await saveDecisionToDb({
scope: 'arch',
decision: 'Test decision',
choice: 'Option A',
rationale: 'Best option',
when_context: 'M001',
}, tmpDir);
assert.deepStrictEqual(result.id, 'D001', 'saveDecisionToDb returns D001 as first ID');
// Verify DB state
const dbDecision = getDecisionById('D001');
assert.ok(!!dbDecision, 'decision exists in DB after save');
assert.deepStrictEqual(dbDecision?.scope, 'arch', 'DB decision has correct scope');
assert.deepStrictEqual(dbDecision?.choice, 'Option A', 'DB decision has correct choice');
// Verify markdown file was written
const mdPath = path.join(tmpDir, '.gsd', 'DECISIONS.md');
assert.ok(fs.existsSync(mdPath), 'DECISIONS.md file created');
const mdContent = fs.readFileSync(mdPath, 'utf-8');
assert.ok(mdContent.includes('D001'), 'DECISIONS.md contains new decision ID');
assert.ok(mdContent.includes('Test decision'), 'DECISIONS.md contains decision text');
// Verify round-trip of the written file
const parsed = parseDecisionsTable(mdContent);
assert.deepStrictEqual(parsed.length, 1, 'written DECISIONS.md parses to 1 decision');
assert.deepStrictEqual(parsed[0].id, 'D001', 'parsed decision has correct ID');
// Add second decision
const result2 = await saveDecisionToDb({
scope: 'impl',
decision: 'Second decision',
choice: 'Option B',
rationale: 'Also good',
}, tmpDir);
assert.deepStrictEqual(result2.id, 'D002', 'second decision gets D002');
const mdContent2 = fs.readFileSync(mdPath, 'utf-8');
const parsed2 = parseDecisionsTable(mdContent2);
assert.deepStrictEqual(parsed2.length, 2, 'DECISIONS.md now has 2 decisions');
} finally {
closeDatabase();
cleanupDir(tmpDir);
}
});
// ═══════════════════════════════════════════════════════════════════════════
// updateRequirementInDb Tests
// ═══════════════════════════════════════════════════════════════════════════
test('updateRequirementInDb', async () => {
const tmpDir = makeTmpDir();
const dbPath = path.join(tmpDir, '.gsd', 'gsd.db');
openDatabase(dbPath);
try {
// Seed a requirement
upsertRequirement({
id: 'R001',
class: 'core-capability',
status: 'active',
description: 'Test requirement',
why: 'Testing',
source: 'test',
primary_owner: 'M001/S01',
supporting_slices: 'none',
validation: 'unmapped',
notes: '',
full_content: '',
superseded_by: null,
});
// Update it
await updateRequirementInDb('R001', {
status: 'validated',
validation: 'S01 — all tests pass',
notes: 'Validated in S01',
}, tmpDir);
// Verify DB state
const updated = getRequirementById('R001');
assert.ok(!!updated, 'requirement still exists after update');
assert.deepStrictEqual(updated?.status, 'validated', 'status updated in DB');
assert.deepStrictEqual(updated?.validation, 'S01 — all tests pass', 'validation updated in DB');
assert.deepStrictEqual(updated?.description, 'Test requirement', 'description preserved after update');
// Verify markdown file was written
const mdPath = path.join(tmpDir, '.gsd', 'REQUIREMENTS.md');
assert.ok(fs.existsSync(mdPath), 'REQUIREMENTS.md file created');
const mdContent = fs.readFileSync(mdPath, 'utf-8');
assert.ok(mdContent.includes('R001'), 'REQUIREMENTS.md contains requirement ID');
assert.ok(mdContent.includes('validated'), 'REQUIREMENTS.md shows updated status');
// Verify round-trip
const parsed = parseRequirementsSections(mdContent);
assert.deepStrictEqual(parsed.length, 1, 'parsed 1 requirement from written file');
assert.deepStrictEqual(parsed[0].status, 'validated', 'parsed status matches update');
} finally {
closeDatabase();
cleanupDir(tmpDir);
}
});
test('updateRequirementInDb — not found', async () => {
const tmpDir = makeTmpDir();
const dbPath = path.join(tmpDir, '.gsd', 'gsd.db');
openDatabase(dbPath);
try {
let threw = false;
try {
await updateRequirementInDb('R999', { status: 'validated' }, tmpDir);
} catch (err) {
threw = true;
assert.ok(
(err as Error).message.includes('R999'),
'error message mentions the missing ID',
);
}
assert.ok(threw, 'throws when requirement not found');
} finally {
closeDatabase();
cleanupDir(tmpDir);
}
});
// ═══════════════════════════════════════════════════════════════════════════
// saveArtifactToDb Tests
// ═══════════════════════════════════════════════════════════════════════════
test('saveArtifactToDb', async () => {
const tmpDir = makeTmpDir();
const dbPath = path.join(tmpDir, '.gsd', 'gsd.db');
openDatabase(dbPath);
try {
const content = '# Task Summary\n\nTest content\n';
await saveArtifactToDb({
path: 'milestones/M001/slices/S06/tasks/T01-SUMMARY.md',
artifact_type: 'SUMMARY',
content,
milestone_id: 'M001',
slice_id: 'S06',
task_id: 'T01',
}, tmpDir);
// Verify DB state
const adapter = _getAdapter();
assert.ok(!!adapter, 'adapter available');
const row = adapter!
.prepare('SELECT * FROM artifacts WHERE path = ?')
.get('milestones/M001/slices/S06/tasks/T01-SUMMARY.md');
assert.ok(!!row, 'artifact exists in DB');
assert.deepStrictEqual(row!['artifact_type'], 'SUMMARY', 'artifact type correct in DB');
assert.deepStrictEqual(row!['milestone_id'], 'M001', 'milestone_id correct in DB');
assert.deepStrictEqual(row!['slice_id'], 'S06', 'slice_id correct in DB');
assert.deepStrictEqual(row!['task_id'], 'T01', 'task_id correct in DB');
// Verify file on disk
const filePath = path.join(
tmpDir, '.gsd', 'milestones', 'M001', 'slices', 'S06', 'tasks', 'T01-SUMMARY.md',
);
assert.ok(fs.existsSync(filePath), 'artifact file written to disk');
assert.deepStrictEqual(fs.readFileSync(filePath, 'utf-8'), content, 'file content matches');
} finally {
closeDatabase();
cleanupDir(tmpDir);
}
assertTrue(threw, 'throws when requirement not found');
} finally {
});
// ═══════════════════════════════════════════════════════════════════════════
// Full Round-Trip: DB → Markdown → Parse → Compare
// ═══════════════════════════════════════════════════════════════════════════
test('Full DB round-trip: decisions', () => {
openDatabase(':memory:');
// Insert via DB
for (const d of SAMPLE_DECISIONS) {
upsertDecision({
id: d.id,
when_context: d.when_context,
scope: d.scope,
decision: d.decision,
choice: d.choice,
rationale: d.rationale,
revisable: d.revisable,
made_by: d.made_by,
superseded_by: d.superseded_by,
});
}
// Generate markdown from DB state
const adapter = _getAdapter()!;
const rows = adapter.prepare('SELECT * FROM decisions ORDER BY seq').all();
const dbDecisions: Decision[] = rows.map(row => ({
seq: row['seq'] as number,
id: row['id'] as string,
when_context: row['when_context'] as string,
scope: row['scope'] as string,
decision: row['decision'] as string,
choice: row['choice'] as string,
rationale: row['rationale'] as string,
revisable: row['revisable'] as string,
made_by: (row['made_by'] as string as import('../types.js').DecisionMadeBy) ?? 'agent',
superseded_by: (row['superseded_by'] as string) ?? null,
}));
const md = generateDecisionsMd(dbDecisions);
const parsed = parseDecisionsTable(md);
assert.deepStrictEqual(parsed.length, SAMPLE_DECISIONS.length, 'DB round-trip decision count');
for (const orig of SAMPLE_DECISIONS) {
const rt = parsed.find(p => p.id === orig.id);
assert.ok(!!rt, `DB round-trip: ${orig.id} found`);
if (rt) {
assert.deepStrictEqual(rt.scope, orig.scope, `DB round-trip: ${orig.id} scope`);
assert.deepStrictEqual(rt.choice, orig.choice, `DB round-trip: ${orig.id} choice`);
}
}
closeDatabase();
cleanupDir(tmpDir);
}
}
});
// ═══════════════════════════════════════════════════════════════════════════
// saveArtifactToDb Tests
// ═══════════════════════════════════════════════════════════════════════════
test('Full DB round-trip: requirements', () => {
openDatabase(':memory:');
console.log('\n── saveArtifactToDb ──');
for (const r of SAMPLE_REQUIREMENTS) {
upsertRequirement(r);
}
{
const tmpDir = makeTmpDir();
const dbPath = path.join(tmpDir, '.gsd', 'gsd.db');
openDatabase(dbPath);
const adapter = _getAdapter()!;
const rows = adapter.prepare('SELECT * FROM requirements ORDER BY id').all();
const dbReqs: Requirement[] = rows.map(row => ({
id: row['id'] as string,
class: row['class'] as string,
status: row['status'] as string,
description: row['description'] as string,
why: row['why'] as string,
source: row['source'] as string,
primary_owner: row['primary_owner'] as string,
supporting_slices: row['supporting_slices'] as string,
validation: row['validation'] as string,
notes: row['notes'] as string,
full_content: row['full_content'] as string,
superseded_by: (row['superseded_by'] as string) ?? null,
}));
try {
const content = '# Task Summary\n\nTest content\n';
await saveArtifactToDb({
path: 'milestones/M001/slices/S06/tasks/T01-SUMMARY.md',
artifact_type: 'SUMMARY',
content,
milestone_id: 'M001',
slice_id: 'S06',
task_id: 'T01',
}, tmpDir);
const md = generateRequirementsMd(dbReqs);
const parsed = parseRequirementsSections(md);
// Verify DB state
const adapter = _getAdapter();
assertTrue(!!adapter, 'adapter available');
const row = adapter!
.prepare('SELECT * FROM artifacts WHERE path = ?')
.get('milestones/M001/slices/S06/tasks/T01-SUMMARY.md');
assertTrue(!!row, 'artifact exists in DB');
assertEq(row!['artifact_type'], 'SUMMARY', 'artifact type correct in DB');
assertEq(row!['milestone_id'], 'M001', 'milestone_id correct in DB');
assertEq(row!['slice_id'], 'S06', 'slice_id correct in DB');
assertEq(row!['task_id'], 'T01', 'task_id correct in DB');
assert.deepStrictEqual(parsed.length, SAMPLE_REQUIREMENTS.length, 'DB round-trip requirement count');
for (const orig of SAMPLE_REQUIREMENTS) {
const rt = parsed.find(p => p.id === orig.id);
assert.ok(!!rt, `DB round-trip: ${orig.id} found`);
if (rt) {
assert.deepStrictEqual(rt.class, orig.class, `DB round-trip: ${orig.id} class`);
assert.deepStrictEqual(rt.description, orig.description, `DB round-trip: ${orig.id} description`);
}
}
// Verify file on disk
const filePath = path.join(
tmpDir, '.gsd', 'milestones', 'M001', 'slices', 'S06', 'tasks', 'T01-SUMMARY.md',
);
assertTrue(fs.existsSync(filePath), 'artifact file written to disk');
assertEq(fs.readFileSync(filePath, 'utf-8'), content, 'file content matches');
} finally {
closeDatabase();
cleanupDir(tmpDir);
}
}
});
// ═══════════════════════════════════════════════════════════════════════════
// Full Round-Trip: DB → Markdown → Parse → Compare
// ═══════════════════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n── Full DB round-trip: decisions ──');
{
openDatabase(':memory:');
// Insert via DB
for (const d of SAMPLE_DECISIONS) {
upsertDecision({
id: d.id,
when_context: d.when_context,
scope: d.scope,
decision: d.decision,
choice: d.choice,
rationale: d.rationale,
revisable: d.revisable,
made_by: d.made_by,
superseded_by: d.superseded_by,
});
}
// Generate markdown from DB state
const adapter = _getAdapter()!;
const rows = adapter.prepare('SELECT * FROM decisions ORDER BY seq').all();
const dbDecisions: Decision[] = rows.map(row => ({
seq: row['seq'] as number,
id: row['id'] as string,
when_context: row['when_context'] as string,
scope: row['scope'] as string,
decision: row['decision'] as string,
choice: row['choice'] as string,
rationale: row['rationale'] as string,
revisable: row['revisable'] as string,
made_by: (row['made_by'] as string as import('../types.js').DecisionMadeBy) ?? 'agent',
superseded_by: (row['superseded_by'] as string) ?? null,
}));
const md = generateDecisionsMd(dbDecisions);
const parsed = parseDecisionsTable(md);
assertEq(parsed.length, SAMPLE_DECISIONS.length, 'DB round-trip decision count');
for (const orig of SAMPLE_DECISIONS) {
const rt = parsed.find(p => p.id === orig.id);
assertTrue(!!rt, `DB round-trip: ${orig.id} found`);
if (rt) {
assertEq(rt.scope, orig.scope, `DB round-trip: ${orig.id} scope`);
assertEq(rt.choice, orig.choice, `DB round-trip: ${orig.id} choice`);
}
}
closeDatabase();
}
console.log('\n── Full DB round-trip: requirements ──');
{
openDatabase(':memory:');
for (const r of SAMPLE_REQUIREMENTS) {
upsertRequirement(r);
}
const adapter = _getAdapter()!;
const rows = adapter.prepare('SELECT * FROM requirements ORDER BY id').all();
const dbReqs: Requirement[] = rows.map(row => ({
id: row['id'] as string,
class: row['class'] as string,
status: row['status'] as string,
description: row['description'] as string,
why: row['why'] as string,
source: row['source'] as string,
primary_owner: row['primary_owner'] as string,
supporting_slices: row['supporting_slices'] as string,
validation: row['validation'] as string,
notes: row['notes'] as string,
full_content: row['full_content'] as string,
superseded_by: (row['superseded_by'] as string) ?? null,
}));
const md = generateRequirementsMd(dbReqs);
const parsed = parseRequirementsSections(md);
assertEq(parsed.length, SAMPLE_REQUIREMENTS.length, 'DB round-trip requirement count');
for (const orig of SAMPLE_REQUIREMENTS) {
const rt = parsed.find(p => p.id === orig.id);
assertTrue(!!rt, `DB round-trip: ${orig.id} found`);
if (rt) {
assertEq(rt.class, orig.class, `DB round-trip: ${orig.id} class`);
assertEq(rt.description, orig.description, `DB round-trip: ${orig.id} description`);
}
}
closeDatabase();
}
// ═══════════════════════════════════════════════════════════════════════════
report();
});

View file

@ -1,3 +1,5 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
// derive-state-crossval.test.ts — Cross-validation: deriveStateFromDb() vs _deriveStateImpl()
// Proves both paths produce field-identical GSDState across 7 fixture scenarios,
// plus an auto-migration round-trip test.
@ -19,11 +21,8 @@ import {
insertTask,
} from '../gsd-db.ts';
import { migrateHierarchyToDb } from '../md-importer.ts';
import { createTestContext } from './test-helpers.ts';
import type { GSDState } from '../types.ts';
const { assertEq, assertTrue, report } = createTestContext();
// ─── Fixture Helpers ───────────────────────────────────────────────────────
function createFixtureBase(): string {
@ -48,29 +47,29 @@ function cleanup(base: string): void {
*/
function assertStatesEqual(dbState: GSDState, fileState: GSDState, prefix: string): void {
// Phase
assertEq(dbState.phase, fileState.phase, `${prefix}: phase`);
assert.deepStrictEqual(dbState.phase, fileState.phase, `${prefix}: phase`);
// Active refs
assertEq(dbState.activeMilestone?.id ?? null, fileState.activeMilestone?.id ?? null, `${prefix}: activeMilestone.id`);
assertEq(dbState.activeMilestone?.title ?? null, fileState.activeMilestone?.title ?? null, `${prefix}: activeMilestone.title`);
assertEq(dbState.activeSlice?.id ?? null, fileState.activeSlice?.id ?? null, `${prefix}: activeSlice.id`);
assertEq(dbState.activeSlice?.title ?? null, fileState.activeSlice?.title ?? null, `${prefix}: activeSlice.title`);
assertEq(dbState.activeTask?.id ?? null, fileState.activeTask?.id ?? null, `${prefix}: activeTask.id`);
assertEq(dbState.activeTask?.title ?? null, fileState.activeTask?.title ?? null, `${prefix}: activeTask.title`);
assert.deepStrictEqual(dbState.activeMilestone?.id ?? null, fileState.activeMilestone?.id ?? null, `${prefix}: activeMilestone.id`);
assert.deepStrictEqual(dbState.activeMilestone?.title ?? null, fileState.activeMilestone?.title ?? null, `${prefix}: activeMilestone.title`);
assert.deepStrictEqual(dbState.activeSlice?.id ?? null, fileState.activeSlice?.id ?? null, `${prefix}: activeSlice.id`);
assert.deepStrictEqual(dbState.activeSlice?.title ?? null, fileState.activeSlice?.title ?? null, `${prefix}: activeSlice.title`);
assert.deepStrictEqual(dbState.activeTask?.id ?? null, fileState.activeTask?.id ?? null, `${prefix}: activeTask.id`);
assert.deepStrictEqual(dbState.activeTask?.title ?? null, fileState.activeTask?.title ?? null, `${prefix}: activeTask.title`);
// Blockers
assertEq(dbState.blockers.length, fileState.blockers.length, `${prefix}: blockers.length`);
assert.deepStrictEqual(dbState.blockers.length, fileState.blockers.length, `${prefix}: blockers.length`);
// Next action (may differ in wording between paths — compare presence)
assertTrue(typeof dbState.nextAction === 'string', `${prefix}: nextAction is string`);
assert.ok(typeof dbState.nextAction === 'string', `${prefix}: nextAction is string`);
// Registry — length and each entry
assertEq(dbState.registry.length, fileState.registry.length, `${prefix}: registry.length`);
assert.deepStrictEqual(dbState.registry.length, fileState.registry.length, `${prefix}: registry.length`);
for (let i = 0; i < fileState.registry.length; i++) {
assertEq(dbState.registry[i]?.id, fileState.registry[i]?.id, `${prefix}: registry[${i}].id`);
assertEq(dbState.registry[i]?.status, fileState.registry[i]?.status, `${prefix}: registry[${i}].status`);
assert.deepStrictEqual(dbState.registry[i]?.id, fileState.registry[i]?.id, `${prefix}: registry[${i}].id`);
assert.deepStrictEqual(dbState.registry[i]?.status, fileState.registry[i]?.status, `${prefix}: registry[${i}].status`);
// dependsOn may or may not be present
assertEq(
assert.deepStrictEqual(
JSON.stringify(dbState.registry[i]?.dependsOn ?? []),
JSON.stringify(fileState.registry[i]?.dependsOn ?? []),
`${prefix}: registry[${i}].dependsOn`,
@ -78,28 +77,27 @@ function assertStatesEqual(dbState: GSDState, fileState: GSDState, prefix: strin
}
// Requirements
assertEq(dbState.requirements?.active ?? 0, fileState.requirements?.active ?? 0, `${prefix}: requirements.active`);
assertEq(dbState.requirements?.validated ?? 0, fileState.requirements?.validated ?? 0, `${prefix}: requirements.validated`);
assertEq(dbState.requirements?.total ?? 0, fileState.requirements?.total ?? 0, `${prefix}: requirements.total`);
assert.deepStrictEqual(dbState.requirements?.active ?? 0, fileState.requirements?.active ?? 0, `${prefix}: requirements.active`);
assert.deepStrictEqual(dbState.requirements?.validated ?? 0, fileState.requirements?.validated ?? 0, `${prefix}: requirements.validated`);
assert.deepStrictEqual(dbState.requirements?.total ?? 0, fileState.requirements?.total ?? 0, `${prefix}: requirements.total`);
// Progress
assertEq(dbState.progress?.milestones?.done, fileState.progress?.milestones?.done, `${prefix}: progress.milestones.done`);
assertEq(dbState.progress?.milestones?.total, fileState.progress?.milestones?.total, `${prefix}: progress.milestones.total`);
assertEq(dbState.progress?.slices?.done ?? 0, fileState.progress?.slices?.done ?? 0, `${prefix}: progress.slices.done`);
assertEq(dbState.progress?.slices?.total ?? 0, fileState.progress?.slices?.total ?? 0, `${prefix}: progress.slices.total`);
assertEq(dbState.progress?.tasks?.done ?? 0, fileState.progress?.tasks?.done ?? 0, `${prefix}: progress.tasks.done`);
assertEq(dbState.progress?.tasks?.total ?? 0, fileState.progress?.tasks?.total ?? 0, `${prefix}: progress.tasks.total`);
assert.deepStrictEqual(dbState.progress?.milestones?.done, fileState.progress?.milestones?.done, `${prefix}: progress.milestones.done`);
assert.deepStrictEqual(dbState.progress?.milestones?.total, fileState.progress?.milestones?.total, `${prefix}: progress.milestones.total`);
assert.deepStrictEqual(dbState.progress?.slices?.done ?? 0, fileState.progress?.slices?.done ?? 0, `${prefix}: progress.slices.done`);
assert.deepStrictEqual(dbState.progress?.slices?.total ?? 0, fileState.progress?.slices?.total ?? 0, `${prefix}: progress.slices.total`);
assert.deepStrictEqual(dbState.progress?.tasks?.done ?? 0, fileState.progress?.tasks?.done ?? 0, `${prefix}: progress.tasks.done`);
assert.deepStrictEqual(dbState.progress?.tasks?.total ?? 0, fileState.progress?.tasks?.total ?? 0, `${prefix}: progress.tasks.total`);
}
// ═══════════════════════════════════════════════════════════════════════════
// Scenario fixtures
// ═══════════════════════════════════════════════════════════════════════════
async function main(): Promise<void> {
describe('derive-state-crossval', async () => {
// ─── Scenario A: Pre-planning — milestone with CONTEXT but no roadmap ──
console.log('\n=== crossval A: pre-planning ===');
{
test('crossval A: pre-planning', async () => {
const base = createFixtureBase();
try {
writeFile(base, 'milestones/M001/M001-CONTEXT.md', '# M001: New Project\n\nWe are exploring scope.');
@ -116,18 +114,17 @@ async function main(): Promise<void> {
const dbState = await deriveStateFromDb(base);
assertStatesEqual(dbState, fileState, 'A-preplan');
assertEq(dbState.phase, 'pre-planning', 'A-preplan: phase is pre-planning');
assert.deepStrictEqual(dbState.phase, 'pre-planning', 'A-preplan: phase is pre-planning');
closeDatabase();
} finally {
closeDatabase();
cleanup(base);
}
}
});
// ─── Scenario B: Executing — 2 slices, first complete, second active ──
console.log('\n=== crossval B: executing ===');
{
test('crossval B: executing', async () => {
const base = createFixtureBase();
try {
const roadmap = `# M001: Test Project
@ -182,20 +179,19 @@ skills_used: []
const dbState = await deriveStateFromDb(base);
assertStatesEqual(dbState, fileState, 'B-executing');
assertEq(dbState.phase, 'executing', 'B-executing: phase is executing');
assertEq(dbState.activeSlice?.id, 'S02', 'B-executing: activeSlice is S02');
assertEq(dbState.activeTask?.id, 'T02', 'B-executing: activeTask is T02');
assert.deepStrictEqual(dbState.phase, 'executing', 'B-executing: phase is executing');
assert.deepStrictEqual(dbState.activeSlice?.id, 'S02', 'B-executing: activeSlice is S02');
assert.deepStrictEqual(dbState.activeTask?.id, 'T02', 'B-executing: activeTask is T02');
closeDatabase();
} finally {
closeDatabase();
cleanup(base);
}
}
});
// ─── Scenario C: Summarizing — all tasks done, no slice summary ────────
console.log('\n=== crossval C: summarizing ===');
{
test('crossval C: summarizing', async () => {
const base = createFixtureBase();
try {
const roadmap = `# M001: Summarize Test
@ -245,20 +241,19 @@ skills_used: []
const dbState = await deriveStateFromDb(base);
assertStatesEqual(dbState, fileState, 'C-summarizing');
assertEq(dbState.phase, 'summarizing', 'C-summarizing: phase is summarizing');
assertEq(dbState.activeSlice?.id, 'S01', 'C-summarizing: activeSlice is S01');
assertEq(dbState.activeTask, null, 'C-summarizing: no activeTask');
assert.deepStrictEqual(dbState.phase, 'summarizing', 'C-summarizing: phase is summarizing');
assert.deepStrictEqual(dbState.activeSlice?.id, 'S01', 'C-summarizing: activeSlice is S01');
assert.deepStrictEqual(dbState.activeTask, null, 'C-summarizing: no activeTask');
closeDatabase();
} finally {
closeDatabase();
cleanup(base);
}
}
});
// ─── Scenario D: Multi-milestone — M001 complete, M002 active ─────────
console.log('\n=== crossval D: multi-milestone ===');
{
test('crossval D: multi-milestone', async () => {
const base = createFixtureBase();
try {
const m1Roadmap = `# M001: First Milestone
@ -313,24 +308,23 @@ skills_used: []
const dbState = await deriveStateFromDb(base);
assertStatesEqual(dbState, fileState, 'D-multims');
assertEq(dbState.activeMilestone?.id, 'M002', 'D-multims: activeMilestone is M002');
assertEq(dbState.registry.length, 2, 'D-multims: 2 milestones in registry');
assert.deepStrictEqual(dbState.activeMilestone?.id, 'M002', 'D-multims: activeMilestone is M002');
assert.deepStrictEqual(dbState.registry.length, 2, 'D-multims: 2 milestones in registry');
const m1 = dbState.registry.find(e => e.id === 'M001');
const m2 = dbState.registry.find(e => e.id === 'M002');
assertEq(m1?.status, 'complete', 'D-multims: M001 complete');
assertEq(m2?.status, 'active', 'D-multims: M002 active');
assert.deepStrictEqual(m1?.status, 'complete', 'D-multims: M001 complete');
assert.deepStrictEqual(m2?.status, 'active', 'D-multims: M002 active');
closeDatabase();
} finally {
closeDatabase();
cleanup(base);
}
}
});
// ─── Scenario E: Blocked — circular slice deps ────────────────────────
console.log('\n=== crossval E: blocked ===');
{
test('crossval E: blocked', async () => {
const base = createFixtureBase();
try {
const roadmap = `# M001: Blocked Test
@ -357,19 +351,18 @@ skills_used: []
const dbState = await deriveStateFromDb(base);
assertStatesEqual(dbState, fileState, 'E-blocked');
assertEq(dbState.phase, 'blocked', 'E-blocked: phase is blocked');
assertTrue(dbState.blockers.length > 0, 'E-blocked: has blockers');
assert.deepStrictEqual(dbState.phase, 'blocked', 'E-blocked: phase is blocked');
assert.ok(dbState.blockers.length > 0, 'E-blocked: has blockers');
closeDatabase();
} finally {
closeDatabase();
cleanup(base);
}
}
});
// ─── Scenario F: Parked — PARKED file on milestone ────────────────────
console.log('\n=== crossval F: parked ===');
{
test('crossval F: parked', async () => {
const base = createFixtureBase();
try {
const roadmap = `# M001: Parked Milestone
@ -396,20 +389,19 @@ skills_used: []
const dbState = await deriveStateFromDb(base);
assertStatesEqual(dbState, fileState, 'F-parked');
assertEq(dbState.activeMilestone?.id, 'M002', 'F-parked: activeMilestone is M002');
assertTrue(dbState.registry.some(e => e.id === 'M001' && e.status === 'parked'), 'F-parked: M001 parked');
assert.deepStrictEqual(dbState.activeMilestone?.id, 'M002', 'F-parked: activeMilestone is M002');
assert.ok(dbState.registry.some(e => e.id === 'M001' && e.status === 'parked'), 'F-parked: M001 parked');
closeDatabase();
} finally {
closeDatabase();
cleanup(base);
}
}
});
// ─── Scenario G: Auto-migration round-trip ────────────────────────────
// Create a markdown-only fixture (no DB). Migrate to DB. Both paths identical.
console.log('\n=== crossval G: auto-migration round-trip ===');
{
test('crossval G: auto-migration round-trip', async () => {
const base = createFixtureBase();
try {
const roadmap = `# M001: Migration Test
@ -489,9 +481,9 @@ skills_used: []
const counts = migrateHierarchyToDb(base);
// Verify migration populated correctly
assertTrue(counts.milestones >= 1, 'G-roundtrip: migrated milestones');
assertTrue(counts.slices >= 2, 'G-roundtrip: migrated slices');
assertTrue(counts.tasks >= 3, 'G-roundtrip: migrated tasks');
assert.ok(counts.milestones >= 1, 'G-roundtrip: migrated milestones');
assert.ok(counts.slices >= 2, 'G-roundtrip: migrated slices');
assert.ok(counts.tasks >= 3, 'G-roundtrip: migrated tasks');
// Step 3: Get DB-backed state
invalidateStateCache();
@ -499,29 +491,22 @@ skills_used: []
// Step 4: Deep cross-validation
assertStatesEqual(dbState, fileState, 'G-roundtrip');
assertEq(dbState.phase, 'executing', 'G-roundtrip: phase is executing');
assertEq(dbState.activeSlice?.id, 'S02', 'G-roundtrip: activeSlice is S02');
assertEq(dbState.activeTask?.id, 'T02', 'G-roundtrip: activeTask is T02');
assertEq(dbState.requirements?.active, 1, 'G-roundtrip: requirements.active = 1');
assertEq(dbState.requirements?.validated, 1, 'G-roundtrip: requirements.validated = 1');
assertEq(dbState.requirements?.deferred, 1, 'G-roundtrip: requirements.deferred = 1');
assertEq(dbState.requirements?.total, 3, 'G-roundtrip: requirements.total = 3');
assertEq(dbState.progress?.slices?.done, 1, 'G-roundtrip: slices.done = 1');
assertEq(dbState.progress?.slices?.total, 3, 'G-roundtrip: slices.total = 3');
assertEq(dbState.progress?.tasks?.done, 1, 'G-roundtrip: tasks.done = 1');
assertEq(dbState.progress?.tasks?.total, 3, 'G-roundtrip: tasks.total = 3');
assert.deepStrictEqual(dbState.phase, 'executing', 'G-roundtrip: phase is executing');
assert.deepStrictEqual(dbState.activeSlice?.id, 'S02', 'G-roundtrip: activeSlice is S02');
assert.deepStrictEqual(dbState.activeTask?.id, 'T02', 'G-roundtrip: activeTask is T02');
assert.deepStrictEqual(dbState.requirements?.active, 1, 'G-roundtrip: requirements.active = 1');
assert.deepStrictEqual(dbState.requirements?.validated, 1, 'G-roundtrip: requirements.validated = 1');
assert.deepStrictEqual(dbState.requirements?.deferred, 1, 'G-roundtrip: requirements.deferred = 1');
assert.deepStrictEqual(dbState.requirements?.total, 3, 'G-roundtrip: requirements.total = 3');
assert.deepStrictEqual(dbState.progress?.slices?.done, 1, 'G-roundtrip: slices.done = 1');
assert.deepStrictEqual(dbState.progress?.slices?.total, 3, 'G-roundtrip: slices.total = 3');
assert.deepStrictEqual(dbState.progress?.tasks?.done, 1, 'G-roundtrip: tasks.done = 1');
assert.deepStrictEqual(dbState.progress?.tasks?.total, 3, 'G-roundtrip: tasks.total = 3');
closeDatabase();
} finally {
closeDatabase();
cleanup(base);
}
}
report();
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
});

View file

@ -1,3 +1,5 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
@ -12,10 +14,6 @@ import {
insertSlice,
insertTask,
} from '../gsd-db.ts';
import { createTestContext } from './test-helpers.ts';
const { assertEq, assertTrue, report } = createTestContext();
// ─── Fixture Helpers ───────────────────────────────────────────────────────
function createFixtureBase(): string {
@ -100,11 +98,10 @@ const REQUIREMENTS_CONTENT = `# Requirements
- Description: Already validated.
`;
async function main(): Promise<void> {
describe('derive-state-db', async () => {
// ─── Test 1: DB-backed deriveState produces identical GSDState ─────────
console.log('\n=== derive-state-db: DB path matches file path ===');
{
test('derive-state-db: DB path matches file path', async () => {
const base = createFixtureBase();
try {
// Write files to disk (for file-only path)
@ -120,7 +117,7 @@ async function main(): Promise<void> {
// Now open DB, insert matching artifacts
openDatabase(':memory:');
assertTrue(isDbAvailable(), 'db-match: DB is available after open');
assert.ok(isDbAvailable(), 'db-match: DB is available after open');
insertArtifactRow('milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT, {
artifact_type: 'roadmap',
@ -140,36 +137,35 @@ async function main(): Promise<void> {
const dbState = await deriveState(base);
// Field-by-field equality
assertEq(dbState.phase, fileState.phase, 'db-match: phase matches');
assertEq(dbState.activeMilestone?.id, fileState.activeMilestone?.id, 'db-match: activeMilestone.id matches');
assertEq(dbState.activeMilestone?.title, fileState.activeMilestone?.title, 'db-match: activeMilestone.title matches');
assertEq(dbState.activeSlice?.id, fileState.activeSlice?.id, 'db-match: activeSlice.id matches');
assertEq(dbState.activeSlice?.title, fileState.activeSlice?.title, 'db-match: activeSlice.title matches');
assertEq(dbState.activeTask?.id, fileState.activeTask?.id, 'db-match: activeTask.id matches');
assertEq(dbState.activeTask?.title, fileState.activeTask?.title, 'db-match: activeTask.title matches');
assertEq(dbState.blockers, fileState.blockers, 'db-match: blockers match');
assertEq(dbState.registry.length, fileState.registry.length, 'db-match: registry length matches');
assertEq(dbState.registry[0]?.status, fileState.registry[0]?.status, 'db-match: registry[0] status matches');
assertEq(dbState.requirements?.active, fileState.requirements?.active, 'db-match: requirements.active matches');
assertEq(dbState.requirements?.validated, fileState.requirements?.validated, 'db-match: requirements.validated matches');
assertEq(dbState.requirements?.total, fileState.requirements?.total, 'db-match: requirements.total matches');
assertEq(dbState.progress?.milestones?.done, fileState.progress?.milestones?.done, 'db-match: milestones.done matches');
assertEq(dbState.progress?.milestones?.total, fileState.progress?.milestones?.total, 'db-match: milestones.total matches');
assertEq(dbState.progress?.slices?.done, fileState.progress?.slices?.done, 'db-match: slices.done matches');
assertEq(dbState.progress?.slices?.total, fileState.progress?.slices?.total, 'db-match: slices.total matches');
assertEq(dbState.progress?.tasks?.done, fileState.progress?.tasks?.done, 'db-match: tasks.done matches');
assertEq(dbState.progress?.tasks?.total, fileState.progress?.tasks?.total, 'db-match: tasks.total matches');
assert.deepStrictEqual(dbState.phase, fileState.phase, 'db-match: phase matches');
assert.deepStrictEqual(dbState.activeMilestone?.id, fileState.activeMilestone?.id, 'db-match: activeMilestone.id matches');
assert.deepStrictEqual(dbState.activeMilestone?.title, fileState.activeMilestone?.title, 'db-match: activeMilestone.title matches');
assert.deepStrictEqual(dbState.activeSlice?.id, fileState.activeSlice?.id, 'db-match: activeSlice.id matches');
assert.deepStrictEqual(dbState.activeSlice?.title, fileState.activeSlice?.title, 'db-match: activeSlice.title matches');
assert.deepStrictEqual(dbState.activeTask?.id, fileState.activeTask?.id, 'db-match: activeTask.id matches');
assert.deepStrictEqual(dbState.activeTask?.title, fileState.activeTask?.title, 'db-match: activeTask.title matches');
assert.deepStrictEqual(dbState.blockers, fileState.blockers, 'db-match: blockers match');
assert.deepStrictEqual(dbState.registry.length, fileState.registry.length, 'db-match: registry length matches');
assert.deepStrictEqual(dbState.registry[0]?.status, fileState.registry[0]?.status, 'db-match: registry[0] status matches');
assert.deepStrictEqual(dbState.requirements?.active, fileState.requirements?.active, 'db-match: requirements.active matches');
assert.deepStrictEqual(dbState.requirements?.validated, fileState.requirements?.validated, 'db-match: requirements.validated matches');
assert.deepStrictEqual(dbState.requirements?.total, fileState.requirements?.total, 'db-match: requirements.total matches');
assert.deepStrictEqual(dbState.progress?.milestones?.done, fileState.progress?.milestones?.done, 'db-match: milestones.done matches');
assert.deepStrictEqual(dbState.progress?.milestones?.total, fileState.progress?.milestones?.total, 'db-match: milestones.total matches');
assert.deepStrictEqual(dbState.progress?.slices?.done, fileState.progress?.slices?.done, 'db-match: slices.done matches');
assert.deepStrictEqual(dbState.progress?.slices?.total, fileState.progress?.slices?.total, 'db-match: slices.total matches');
assert.deepStrictEqual(dbState.progress?.tasks?.done, fileState.progress?.tasks?.done, 'db-match: tasks.done matches');
assert.deepStrictEqual(dbState.progress?.tasks?.total, fileState.progress?.tasks?.total, 'db-match: tasks.total matches');
closeDatabase();
} finally {
closeDatabase();
cleanup(base);
}
}
});
// ─── Test 2: Fallback when DB unavailable ─────────────────────────────
console.log('\n=== derive-state-db: fallback when DB unavailable ===');
{
test('derive-state-db: fallback when DB unavailable', async () => {
const base = createFixtureBase();
try {
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
@ -178,22 +174,21 @@ async function main(): Promise<void> {
writeFile(base, 'milestones/M001/slices/S01/tasks/T01-PLAN.md', '# T01 Plan');
// No DB open — isDbAvailable() is false
assertTrue(!isDbAvailable(), 'fallback: DB is not available');
assert.ok(!isDbAvailable(), 'fallback: DB is not available');
invalidateStateCache();
const state = await deriveState(base);
assertEq(state.phase, 'executing', 'fallback: phase is executing');
assertEq(state.activeMilestone?.id, 'M001', 'fallback: activeMilestone is M001');
assertEq(state.activeSlice?.id, 'S01', 'fallback: activeSlice is S01');
assertEq(state.activeTask?.id, 'T01', 'fallback: activeTask is T01');
assert.deepStrictEqual(state.phase, 'executing', 'fallback: phase is executing');
assert.deepStrictEqual(state.activeMilestone?.id, 'M001', 'fallback: activeMilestone is M001');
assert.deepStrictEqual(state.activeSlice?.id, 'S01', 'fallback: activeSlice is S01');
assert.deepStrictEqual(state.activeTask?.id, 'T01', 'fallback: activeTask is T01');
} finally {
cleanup(base);
}
}
});
// ─── Test 3: Empty DB falls back to file reads ────────────────────────
console.log('\n=== derive-state-db: empty DB falls back to files ===');
{
test('derive-state-db: empty DB falls back to files', async () => {
const base = createFixtureBase();
try {
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
@ -203,27 +198,26 @@ async function main(): Promise<void> {
// Open DB but insert nothing — empty artifacts table
openDatabase(':memory:');
assertTrue(isDbAvailable(), 'empty-db: DB is available');
assert.ok(isDbAvailable(), 'empty-db: DB is available');
invalidateStateCache();
const state = await deriveState(base);
// Should still work via cachedLoadFile → loadFile disk fallback
assertEq(state.phase, 'executing', 'empty-db: phase is executing');
assertEq(state.activeMilestone?.id, 'M001', 'empty-db: activeMilestone is M001');
assertEq(state.activeSlice?.id, 'S01', 'empty-db: activeSlice is S01');
assertEq(state.activeTask?.id, 'T01', 'empty-db: activeTask is T01');
assert.deepStrictEqual(state.phase, 'executing', 'empty-db: phase is executing');
assert.deepStrictEqual(state.activeMilestone?.id, 'M001', 'empty-db: activeMilestone is M001');
assert.deepStrictEqual(state.activeSlice?.id, 'S01', 'empty-db: activeSlice is S01');
assert.deepStrictEqual(state.activeTask?.id, 'T01', 'empty-db: activeTask is T01');
closeDatabase();
} finally {
closeDatabase();
cleanup(base);
}
}
});
// ─── Test 4: Partial DB content fills gaps from disk ──────────────────
console.log('\n=== derive-state-db: partial DB fills gaps from disk ===');
{
test('derive-state-db: partial DB fills gaps from disk', async () => {
const base = createFixtureBase();
try {
// Write all files to disk
@ -244,25 +238,24 @@ async function main(): Promise<void> {
const state = await deriveState(base);
// Should work: roadmap from DB, plan from disk fallback
assertEq(state.phase, 'executing', 'partial-db: phase is executing');
assertEq(state.activeMilestone?.id, 'M001', 'partial-db: activeMilestone is M001');
assertEq(state.activeSlice?.id, 'S01', 'partial-db: activeSlice is S01');
assertEq(state.activeTask?.id, 'T01', 'partial-db: activeTask is T01');
assert.deepStrictEqual(state.phase, 'executing', 'partial-db: phase is executing');
assert.deepStrictEqual(state.activeMilestone?.id, 'M001', 'partial-db: activeMilestone is M001');
assert.deepStrictEqual(state.activeSlice?.id, 'S01', 'partial-db: activeSlice is S01');
assert.deepStrictEqual(state.activeTask?.id, 'T01', 'partial-db: activeTask is T01');
// Requirements loaded from disk fallback
assertEq(state.requirements?.active, 2, 'partial-db: requirements.active from disk');
assertEq(state.requirements?.validated, 1, 'partial-db: requirements.validated from disk');
assertEq(state.requirements?.total, 3, 'partial-db: requirements.total from disk');
assert.deepStrictEqual(state.requirements?.active, 2, 'partial-db: requirements.active from disk');
assert.deepStrictEqual(state.requirements?.validated, 1, 'partial-db: requirements.validated from disk');
assert.deepStrictEqual(state.requirements?.total, 3, 'partial-db: requirements.total from disk');
closeDatabase();
} finally {
closeDatabase();
cleanup(base);
}
}
});
// ─── Test 5: Requirements counting from disk (DB no longer used for content) ─
console.log('\n=== derive-state-db: requirements from disk content ===');
{
test('derive-state-db: requirements from disk content', async () => {
const base = createFixtureBase();
try {
// Write minimal milestone dir (needed for milestone discovery)
@ -274,17 +267,16 @@ async function main(): Promise<void> {
const state = await deriveState(base);
// Requirements should come from disk
assertEq(state.requirements?.active, 2, 'req-from-disk: requirements.active = 2');
assertEq(state.requirements?.validated, 1, 'req-from-disk: requirements.validated = 1');
assertEq(state.requirements?.total, 3, 'req-from-disk: requirements.total = 3');
assert.deepStrictEqual(state.requirements?.active, 2, 'req-from-disk: requirements.active = 2');
assert.deepStrictEqual(state.requirements?.validated, 1, 'req-from-disk: requirements.validated = 1');
assert.deepStrictEqual(state.requirements?.total, 3, 'req-from-disk: requirements.total = 3');
} finally {
cleanup(base);
}
}
});
// ─── Test 6: DB content with multi-milestone registry ─────────────────
console.log('\n=== derive-state-db: multi-milestone from DB ===');
{
test('derive-state-db: multi-milestone from DB', async () => {
const base = createFixtureBase();
const completedRoadmap = `# M001: First Milestone
@ -337,24 +329,23 @@ async function main(): Promise<void> {
invalidateStateCache();
const state = await deriveState(base);
assertEq(state.registry.length, 2, 'multi-ms-db: registry has 2 entries');
assertEq(state.registry[0]?.id, 'M001', 'multi-ms-db: registry[0] is M001');
assertEq(state.registry[0]?.status, 'complete', 'multi-ms-db: M001 is complete');
assertEq(state.registry[1]?.id, 'M002', 'multi-ms-db: registry[1] is M002');
assertEq(state.registry[1]?.status, 'active', 'multi-ms-db: M002 is active');
assertEq(state.activeMilestone?.id, 'M002', 'multi-ms-db: activeMilestone is M002');
assertEq(state.phase, 'planning', 'multi-ms-db: phase is planning (no plan for S01)');
assert.deepStrictEqual(state.registry.length, 2, 'multi-ms-db: registry has 2 entries');
assert.deepStrictEqual(state.registry[0]?.id, 'M001', 'multi-ms-db: registry[0] is M001');
assert.deepStrictEqual(state.registry[0]?.status, 'complete', 'multi-ms-db: M001 is complete');
assert.deepStrictEqual(state.registry[1]?.id, 'M002', 'multi-ms-db: registry[1] is M002');
assert.deepStrictEqual(state.registry[1]?.status, 'active', 'multi-ms-db: M002 is active');
assert.deepStrictEqual(state.activeMilestone?.id, 'M002', 'multi-ms-db: activeMilestone is M002');
assert.deepStrictEqual(state.phase, 'planning', 'multi-ms-db: phase is planning (no plan for S01)');
closeDatabase();
} finally {
closeDatabase();
cleanup(base);
}
}
});
// ─── Test 7: Cache invalidation works for DB path ─────────────────────
console.log('\n=== derive-state-db: cache invalidation ===');
{
test('derive-state-db: cache invalidation', async () => {
const base = createFixtureBase();
try {
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
@ -375,7 +366,7 @@ async function main(): Promise<void> {
invalidateStateCache();
const state1 = await deriveState(base);
assertEq(state1.activeTask?.id, 'T01', 'cache-inv: first call gets T01');
assert.deepStrictEqual(state1.activeTask?.id, 'T01', 'cache-inv: first call gets T01');
// Simulate task completion by updating the plan in DB
const updatedPlan = PLAN_CONTENT.replace('- [ ] **T01:', '- [x] **T01:');
@ -389,28 +380,27 @@ async function main(): Promise<void> {
// Without invalidation, should return cached result (T01 still active)
const state2 = await deriveState(base);
assertEq(state2.activeTask?.id, 'T01', 'cache-inv: cached result still has T01');
assert.deepStrictEqual(state2.activeTask?.id, 'T01', 'cache-inv: cached result still has T01');
// After invalidation, should pick up updated content
invalidateStateCache();
const state3 = await deriveState(base);
assertEq(state3.phase, 'summarizing', 'cache-inv: after invalidation, phase is summarizing (all tasks done)');
assertEq(state3.activeTask, null, 'cache-inv: activeTask is null after all done');
assert.deepStrictEqual(state3.phase, 'summarizing', 'cache-inv: after invalidation, phase is summarizing (all tasks done)');
assert.deepStrictEqual(state3.activeTask, null, 'cache-inv: activeTask is null after all done');
closeDatabase();
} finally {
closeDatabase();
cleanup(base);
}
}
});
// ═════════════════════════════════════════════════════════════════════════
// New: deriveStateFromDb() cross-validation tests
// ═════════════════════════════════════════════════════════════════════════
// ─── Test 8: Pre-planning — milestone exists, no roadmap, no slices ───
console.log('\n=== derive-state-db: pre-planning via DB ===');
{
test('derive-state-db: pre-planning via DB', async () => {
const base = createFixtureBase();
try {
// Create milestone dir on disk with a CONTEXT file (not a ghost)
@ -427,23 +417,22 @@ async function main(): Promise<void> {
invalidateStateCache();
const dbState = await deriveStateFromDb(base);
assertEq(dbState.phase, fileState.phase, 'pre-plan-db: phase matches');
assertEq(dbState.activeMilestone?.id, fileState.activeMilestone?.id, 'pre-plan-db: activeMilestone.id matches');
assertEq(dbState.activeSlice, fileState.activeSlice, 'pre-plan-db: activeSlice matches');
assertEq(dbState.activeTask, fileState.activeTask, 'pre-plan-db: activeTask matches');
assertEq(dbState.registry.length, fileState.registry.length, 'pre-plan-db: registry length matches');
assertEq(dbState.registry[0]?.status, fileState.registry[0]?.status, 'pre-plan-db: registry[0] status matches');
assert.deepStrictEqual(dbState.phase, fileState.phase, 'pre-plan-db: phase matches');
assert.deepStrictEqual(dbState.activeMilestone?.id, fileState.activeMilestone?.id, 'pre-plan-db: activeMilestone.id matches');
assert.deepStrictEqual(dbState.activeSlice, fileState.activeSlice, 'pre-plan-db: activeSlice matches');
assert.deepStrictEqual(dbState.activeTask, fileState.activeTask, 'pre-plan-db: activeTask matches');
assert.deepStrictEqual(dbState.registry.length, fileState.registry.length, 'pre-plan-db: registry length matches');
assert.deepStrictEqual(dbState.registry[0]?.status, fileState.registry[0]?.status, 'pre-plan-db: registry[0] status matches');
closeDatabase();
} finally {
closeDatabase();
cleanup(base);
}
}
});
// ─── Test 9: Executing — active task with partial completion ──────────
console.log('\n=== derive-state-db: executing via DB ===');
{
test('derive-state-db: executing via DB', async () => {
const base = createFixtureBase();
try {
// Build filesystem fixture
@ -466,24 +455,23 @@ async function main(): Promise<void> {
invalidateStateCache();
const dbState = await deriveStateFromDb(base);
assertEq(dbState.phase, 'executing', 'exec-db: phase is executing');
assertEq(dbState.activeMilestone?.id, 'M001', 'exec-db: activeMilestone is M001');
assertEq(dbState.activeSlice?.id, 'S01', 'exec-db: activeSlice is S01');
assertEq(dbState.activeTask?.id, 'T01', 'exec-db: activeTask is T01');
assertEq(dbState.progress?.tasks?.done, 1, 'exec-db: tasks.done = 1');
assertEq(dbState.progress?.tasks?.total, 2, 'exec-db: tasks.total = 2');
assertEq(dbState.phase, fileState.phase, 'exec-db: phase matches filesystem');
assert.deepStrictEqual(dbState.phase, 'executing', 'exec-db: phase is executing');
assert.deepStrictEqual(dbState.activeMilestone?.id, 'M001', 'exec-db: activeMilestone is M001');
assert.deepStrictEqual(dbState.activeSlice?.id, 'S01', 'exec-db: activeSlice is S01');
assert.deepStrictEqual(dbState.activeTask?.id, 'T01', 'exec-db: activeTask is T01');
assert.deepStrictEqual(dbState.progress?.tasks?.done, 1, 'exec-db: tasks.done = 1');
assert.deepStrictEqual(dbState.progress?.tasks?.total, 2, 'exec-db: tasks.total = 2');
assert.deepStrictEqual(dbState.phase, fileState.phase, 'exec-db: phase matches filesystem');
closeDatabase();
} finally {
closeDatabase();
cleanup(base);
}
}
});
// ─── Test 10: Summarizing — all tasks complete, no slice summary ──────
console.log('\n=== derive-state-db: summarizing via DB ===');
{
test('derive-state-db: summarizing via DB', async () => {
const base = createFixtureBase();
try {
const allDonePlan = `# S01: First Slice
@ -517,21 +505,20 @@ async function main(): Promise<void> {
invalidateStateCache();
const dbState = await deriveStateFromDb(base);
assertEq(dbState.phase, 'summarizing', 'summarize-db: phase is summarizing');
assertEq(dbState.phase, fileState.phase, 'summarize-db: phase matches filesystem');
assertEq(dbState.activeSlice?.id, 'S01', 'summarize-db: activeSlice is S01');
assertEq(dbState.activeTask, null, 'summarize-db: activeTask is null');
assert.deepStrictEqual(dbState.phase, 'summarizing', 'summarize-db: phase is summarizing');
assert.deepStrictEqual(dbState.phase, fileState.phase, 'summarize-db: phase matches filesystem');
assert.deepStrictEqual(dbState.activeSlice?.id, 'S01', 'summarize-db: activeSlice is S01');
assert.deepStrictEqual(dbState.activeTask, null, 'summarize-db: activeTask is null');
closeDatabase();
} finally {
closeDatabase();
cleanup(base);
}
}
});
// ─── Test 11: Complete — all milestones complete ──────────────────────
console.log('\n=== derive-state-db: all complete via DB ===');
{
test('derive-state-db: all complete via DB', async () => {
const base = createFixtureBase();
try {
const completedRoadmap = `# M001: Done Milestone
@ -557,21 +544,20 @@ async function main(): Promise<void> {
invalidateStateCache();
const dbState = await deriveStateFromDb(base);
assertEq(dbState.phase, 'complete', 'complete-db: phase is complete');
assertEq(dbState.phase, fileState.phase, 'complete-db: phase matches filesystem');
assertEq(dbState.registry.length, 1, 'complete-db: registry has 1 entry');
assertEq(dbState.registry[0]?.status, 'complete', 'complete-db: M001 is complete');
assert.deepStrictEqual(dbState.phase, 'complete', 'complete-db: phase is complete');
assert.deepStrictEqual(dbState.phase, fileState.phase, 'complete-db: phase matches filesystem');
assert.deepStrictEqual(dbState.registry.length, 1, 'complete-db: registry has 1 entry');
assert.deepStrictEqual(dbState.registry[0]?.status, 'complete', 'complete-db: M001 is complete');
closeDatabase();
} finally {
closeDatabase();
cleanup(base);
}
}
});
// ─── Test 12: Blocked — slice deps unmet ──────────────────────────────
console.log('\n=== derive-state-db: blocked slice via DB ===');
{
test('derive-state-db: blocked slice via DB', async () => {
const base = createFixtureBase();
try {
// Roadmap with S02 depending on S01, but S01 not done
@ -601,20 +587,19 @@ async function main(): Promise<void> {
invalidateStateCache();
const dbState = await deriveStateFromDb(base);
assertEq(dbState.phase, 'blocked', 'blocked-db: phase is blocked');
assertEq(dbState.phase, fileState.phase, 'blocked-db: phase matches filesystem');
assertTrue(dbState.blockers.length > 0, 'blocked-db: has blockers');
assert.deepStrictEqual(dbState.phase, 'blocked', 'blocked-db: phase is blocked');
assert.deepStrictEqual(dbState.phase, fileState.phase, 'blocked-db: phase matches filesystem');
assert.ok(dbState.blockers.length > 0, 'blocked-db: has blockers');
closeDatabase();
} finally {
closeDatabase();
cleanup(base);
}
}
});
// ─── Test 13: Parked milestone ────────────────────────────────────────
console.log('\n=== derive-state-db: parked milestone via DB ===');
{
test('derive-state-db: parked milestone via DB', async () => {
const base = createFixtureBase();
try {
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
@ -631,20 +616,19 @@ async function main(): Promise<void> {
invalidateStateCache();
const dbState = await deriveStateFromDb(base);
assertEq(dbState.phase, fileState.phase, 'parked-db: phase matches filesystem');
assertEq(dbState.activeMilestone?.id, 'M002', 'parked-db: activeMilestone is M002');
assertTrue(dbState.registry.some(e => e.id === 'M001' && e.status === 'parked'), 'parked-db: M001 is parked in registry');
assert.deepStrictEqual(dbState.phase, fileState.phase, 'parked-db: phase matches filesystem');
assert.deepStrictEqual(dbState.activeMilestone?.id, 'M002', 'parked-db: activeMilestone is M002');
assert.ok(dbState.registry.some(e => e.id === 'M001' && e.status === 'parked'), 'parked-db: M001 is parked in registry');
closeDatabase();
} finally {
closeDatabase();
cleanup(base);
}
}
});
// ─── Test 14: Validating-milestone — all slices done, no terminal validation ─
console.log('\n=== derive-state-db: validating-milestone via DB ===');
{
test('derive-state-db: validating-milestone via DB', async () => {
const base = createFixtureBase();
try {
const doneRoadmap = `# M001: Validate Test
@ -669,20 +653,19 @@ async function main(): Promise<void> {
invalidateStateCache();
const dbState = await deriveStateFromDb(base);
assertEq(dbState.phase, 'validating-milestone', 'validate-db: phase is validating-milestone');
assertEq(dbState.phase, fileState.phase, 'validate-db: phase matches filesystem');
assertEq(dbState.activeMilestone?.id, 'M001', 'validate-db: activeMilestone is M001');
assert.deepStrictEqual(dbState.phase, 'validating-milestone', 'validate-db: phase is validating-milestone');
assert.deepStrictEqual(dbState.phase, fileState.phase, 'validate-db: phase matches filesystem');
assert.deepStrictEqual(dbState.activeMilestone?.id, 'M001', 'validate-db: activeMilestone is M001');
closeDatabase();
} finally {
closeDatabase();
cleanup(base);
}
}
});
// ─── Test 15: Completing-milestone — terminal validation, no summary ──
console.log('\n=== derive-state-db: completing-milestone via DB ===');
{
test('derive-state-db: completing-milestone via DB', async () => {
const base = createFixtureBase();
try {
const doneRoadmap = `# M001: Complete Test
@ -707,19 +690,18 @@ async function main(): Promise<void> {
invalidateStateCache();
const dbState = await deriveStateFromDb(base);
assertEq(dbState.phase, 'completing-milestone', 'completing-db: phase is completing-milestone');
assertEq(dbState.phase, fileState.phase, 'completing-db: phase matches filesystem');
assert.deepStrictEqual(dbState.phase, 'completing-milestone', 'completing-db: phase is completing-milestone');
assert.deepStrictEqual(dbState.phase, fileState.phase, 'completing-db: phase matches filesystem');
closeDatabase();
} finally {
closeDatabase();
cleanup(base);
}
}
});
// ─── Test 16: Replanning-slice — REPLAN-TRIGGER file exists ───────────
console.log('\n=== derive-state-db: replanning-slice via DB ===');
{
test('derive-state-db: replanning-slice via DB', async () => {
const base = createFixtureBase();
try {
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
@ -749,19 +731,18 @@ async function main(): Promise<void> {
invalidateStateCache();
const dbState = await deriveStateFromDb(base);
assertEq(dbState.phase, 'replanning-slice', 'replan-db: phase is replanning-slice');
assertEq(dbState.phase, fileState.phase, 'replan-db: phase matches filesystem');
assert.deepStrictEqual(dbState.phase, 'replanning-slice', 'replan-db: phase is replanning-slice');
assert.deepStrictEqual(dbState.phase, fileState.phase, 'replan-db: phase matches filesystem');
closeDatabase();
} finally {
closeDatabase();
cleanup(base);
}
}
});
// ─── Test 17: Performance — deriveStateFromDb < 1ms on populated DB ───
console.log('\n=== derive-state-db: performance assertion ===');
{
test('derive-state-db: performance assertion', async () => {
const base = createFixtureBase();
try {
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
@ -789,18 +770,17 @@ async function main(): Promise<void> {
console.log(` deriveStateFromDb() took ${elapsed.toFixed(3)}ms`);
// Use 10ms threshold — catches real regressions without flaking on
// CI runners under load (1ms threshold failed at 1.050ms on GitHub Actions)
assertTrue(elapsed < 10, `perf-db: deriveStateFromDb() <10ms (got ${elapsed.toFixed(3)}ms)`);
assert.ok(elapsed < 10, `perf-db: deriveStateFromDb() <10ms (got ${elapsed.toFixed(3)}ms)`);
closeDatabase();
} finally {
closeDatabase();
cleanup(base);
}
}
});
// ─── Test 18: Multi-milestone with deps — M001 complete, M002 depends on M001, M003 depends on M002 ─
console.log('\n=== derive-state-db: multi-milestone deps via DB ===');
{
test('derive-state-db: multi-milestone deps via DB', async () => {
const base = createFixtureBase();
try {
const m1Roadmap = `# M001: First
@ -841,29 +821,28 @@ async function main(): Promise<void> {
invalidateStateCache();
const dbState = await deriveStateFromDb(base);
assertEq(dbState.registry.length, fileState.registry.length, 'multi-deps-db: registry length matches');
assertEq(dbState.activeMilestone?.id, 'M002', 'multi-deps-db: activeMilestone is M002 (M001 complete, M003 dep unmet)');
assertEq(dbState.activeMilestone?.id, fileState.activeMilestone?.id, 'multi-deps-db: activeMilestone matches filesystem');
assertEq(dbState.phase, fileState.phase, 'multi-deps-db: phase matches filesystem');
assert.deepStrictEqual(dbState.registry.length, fileState.registry.length, 'multi-deps-db: registry length matches');
assert.deepStrictEqual(dbState.activeMilestone?.id, 'M002', 'multi-deps-db: activeMilestone is M002 (M001 complete, M003 dep unmet)');
assert.deepStrictEqual(dbState.activeMilestone?.id, fileState.activeMilestone?.id, 'multi-deps-db: activeMilestone matches filesystem');
assert.deepStrictEqual(dbState.phase, fileState.phase, 'multi-deps-db: phase matches filesystem');
// Check registry statuses
const m1reg = dbState.registry.find(e => e.id === 'M001');
const m2reg = dbState.registry.find(e => e.id === 'M002');
const m3reg = dbState.registry.find(e => e.id === 'M003');
assertEq(m1reg?.status, 'complete', 'multi-deps-db: M001 is complete');
assertEq(m2reg?.status, 'active', 'multi-deps-db: M002 is active');
assertEq(m3reg?.status, 'pending', 'multi-deps-db: M003 is pending (dep M002 unmet)');
assert.deepStrictEqual(m1reg?.status, 'complete', 'multi-deps-db: M001 is complete');
assert.deepStrictEqual(m2reg?.status, 'active', 'multi-deps-db: M002 is active');
assert.deepStrictEqual(m3reg?.status, 'pending', 'multi-deps-db: M003 is pending (dep M002 unmet)');
closeDatabase();
} finally {
closeDatabase();
cleanup(base);
}
}
});
// ─── Test 19: K002 — both 'complete' and 'done' treated as done ───────
console.log('\n=== derive-state-db: K002 status handling ===');
{
test('derive-state-db: K002 status handling', async () => {
const base = createFixtureBase();
try {
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
@ -882,20 +861,19 @@ async function main(): Promise<void> {
invalidateStateCache();
const dbState = await deriveStateFromDb(base);
assertEq(dbState.phase, 'executing', 'k002-db: phase is executing');
assertEq(dbState.activeTask?.id, 'T01', 'k002-db: activeTask is T01 (T02 done)');
assertEq(dbState.progress?.tasks?.done, 1, 'k002-db: tasks.done counts done status');
assert.deepStrictEqual(dbState.phase, 'executing', 'k002-db: phase is executing');
assert.deepStrictEqual(dbState.activeTask?.id, 'T01', 'k002-db: activeTask is T01 (T02 done)');
assert.deepStrictEqual(dbState.progress?.tasks?.done, 1, 'k002-db: tasks.done counts done status');
closeDatabase();
} finally {
closeDatabase();
cleanup(base);
}
}
});
// ─── Test 20: Dual-path wiring — deriveState() uses DB when populated ─
console.log('\n=== derive-state-db: dual-path wiring ===');
{
test('derive-state-db: dual-path wiring', async () => {
const base = createFixtureBase();
try {
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
@ -914,21 +892,20 @@ async function main(): Promise<void> {
invalidateStateCache();
const state = await deriveState(base);
assertEq(state.phase, 'executing', 'dual-path: phase is executing');
assertEq(state.activeMilestone?.id, 'M001', 'dual-path: activeMilestone is M001');
assertEq(state.activeSlice?.id, 'S01', 'dual-path: activeSlice is S01');
assertEq(state.activeTask?.id, 'T01', 'dual-path: activeTask is T01');
assert.deepStrictEqual(state.phase, 'executing', 'dual-path: phase is executing');
assert.deepStrictEqual(state.activeMilestone?.id, 'M001', 'dual-path: activeMilestone is M001');
assert.deepStrictEqual(state.activeSlice?.id, 'S01', 'dual-path: activeSlice is S01');
assert.deepStrictEqual(state.activeTask?.id, 'T01', 'dual-path: activeTask is T01');
closeDatabase();
} finally {
closeDatabase();
cleanup(base);
}
}
});
// ─── Test 21: Ghost milestone skipped ─────────────────────────────────
console.log('\n=== derive-state-db: ghost milestone skipped ===');
{
test('derive-state-db: ghost milestone skipped', async () => {
const base = createFixtureBase();
try {
// Ghost: milestone dir exists with only META.json, no context/roadmap/summary
@ -949,21 +926,20 @@ async function main(): Promise<void> {
const dbState = await deriveStateFromDb(base);
// Ghost should be skipped — M002 should be active
assertEq(dbState.activeMilestone?.id, 'M002', 'ghost-db: activeMilestone is M002 (ghost skipped)');
assertEq(dbState.activeMilestone?.id, fileState.activeMilestone?.id, 'ghost-db: matches filesystem');
assert.deepStrictEqual(dbState.activeMilestone?.id, 'M002', 'ghost-db: activeMilestone is M002 (ghost skipped)');
assert.deepStrictEqual(dbState.activeMilestone?.id, fileState.activeMilestone?.id, 'ghost-db: matches filesystem');
// Ghost should not appear in registry
assertTrue(!dbState.registry.some(e => e.id === 'M001'), 'ghost-db: M001 not in registry');
assert.ok(!dbState.registry.some(e => e.id === 'M001'), 'ghost-db: M001 not in registry');
closeDatabase();
} finally {
closeDatabase();
cleanup(base);
}
}
});
// ─── Test 22: Needs-discussion — CONTEXT-DRAFT exists ─────────────────
console.log('\n=== derive-state-db: needs-discussion via DB ===');
{
test('derive-state-db: needs-discussion via DB', async () => {
const base = createFixtureBase();
try {
writeFile(base, 'milestones/M001/M001-CONTEXT-DRAFT.md', '# M001: Draft\n\nDraft content.');
@ -977,20 +953,13 @@ async function main(): Promise<void> {
invalidateStateCache();
const dbState = await deriveStateFromDb(base);
assertEq(dbState.phase, 'needs-discussion', 'discuss-db: phase is needs-discussion');
assertEq(dbState.phase, fileState.phase, 'discuss-db: phase matches filesystem');
assert.deepStrictEqual(dbState.phase, 'needs-discussion', 'discuss-db: phase is needs-discussion');
assert.deepStrictEqual(dbState.phase, fileState.phase, 'discuss-db: phase matches filesystem');
closeDatabase();
} finally {
closeDatabase();
cleanup(base);
}
}
report();
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
});

View file

@ -1,11 +1,10 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { deriveState } from '../state.ts';
import { createTestContext } from './test-helpers.ts';
const { assertEq, assertTrue, report } = createTestContext();
// ─── Fixture Helpers ───────────────────────────────────────────────────────
function createFixtureBase(): string {
@ -63,12 +62,11 @@ function cleanup(base: string): void {
// Test Groups
// ═══════════════════════════════════════════════════════════════════════════
async function main(): Promise<void> {
describe('derive-state-deps', async () => {
// ─── Test Group 1: blocked-deps ────────────────────────────────────────
// M001 is incomplete (no SUMMARY), M002 depends_on M001 → M002 is pending
console.log('\n=== blocked-deps ===');
{
test('blocked-deps', async () => {
const base = createFixtureBase();
try {
// M001: incomplete (one slice, no SUMMARY)
@ -108,19 +106,18 @@ async function main(): Promise<void> {
const state = await deriveState(base);
assertEq(state.registry[0]?.status, 'active', 'blocked-deps: M001 is active');
assertEq(state.registry[1]?.status, 'pending', 'blocked-deps: M002 is pending (dep-blocked)');
assertEq(state.phase, 'executing', 'blocked-deps: phase is executing (M001 is active)');
assertEq(state.activeMilestone?.id, 'M001', 'blocked-deps: activeMilestone is M001');
assert.deepStrictEqual(state.registry[0]?.status, 'active', 'blocked-deps: M001 is active');
assert.deepStrictEqual(state.registry[1]?.status, 'pending', 'blocked-deps: M002 is pending (dep-blocked)');
assert.deepStrictEqual(state.phase, 'executing', 'blocked-deps: phase is executing (M001 is active)');
assert.deepStrictEqual(state.activeMilestone?.id, 'M001', 'blocked-deps: activeMilestone is M001');
} finally {
cleanup(base);
}
}
});
// ─── Test Group 2: unblocked-deps ──────────────────────────────────────
// M001 is complete (all slices [x] + SUMMARY), M002 depends_on M001 → M002 becomes active
console.log('\n=== unblocked-deps ===');
{
test('unblocked-deps', async () => {
const base = createFixtureBase();
try {
// M001: complete (all slices done + SUMMARY present)
@ -150,19 +147,18 @@ async function main(): Promise<void> {
const state = await deriveState(base);
assertEq(state.registry[0]?.status, 'complete', 'unblocked-deps: M001 is complete');
assertEq(state.registry[1]?.status, 'active', 'unblocked-deps: M002 is active');
assertEq(state.activeMilestone?.id, 'M002', 'unblocked-deps: activeMilestone is M002');
assertTrue(state.phase !== 'blocked', 'unblocked-deps: phase is not blocked');
assert.deepStrictEqual(state.registry[0]?.status, 'complete', 'unblocked-deps: M001 is complete');
assert.deepStrictEqual(state.registry[1]?.status, 'active', 'unblocked-deps: M002 is active');
assert.deepStrictEqual(state.activeMilestone?.id, 'M002', 'unblocked-deps: activeMilestone is M002');
assert.ok(state.phase !== 'blocked', 'unblocked-deps: phase is not blocked');
} finally {
cleanup(base);
}
}
});
// ─── Test Group 3: all-blocked ─────────────────────────────────────────
// M001 depends_on M002, M002 depends_on M001 — circular dep, neither can activate
console.log('\n=== all-blocked ===');
{
test('all-blocked', async () => {
const base = createFixtureBase();
try {
// M001: depends on M002
@ -191,18 +187,17 @@ async function main(): Promise<void> {
const state = await deriveState(base);
assertEq(state.phase, 'blocked', 'all-blocked: phase is blocked');
assertTrue(state.activeMilestone === null || state.activeMilestone !== null, 'all-blocked: state is consistent');
assertTrue(state.blockers.length > 0, 'all-blocked: blockers array is non-empty');
assert.deepStrictEqual(state.phase, 'blocked', 'all-blocked: phase is blocked');
assert.ok(state.activeMilestone === null || state.activeMilestone !== null, 'all-blocked: state is consistent');
assert.ok(state.blockers.length > 0, 'all-blocked: blockers array is non-empty');
} finally {
cleanup(base);
}
}
});
// ─── Test Group 4: absent-context ──────────────────────────────────────
// Neither M001 nor M002 has a CONTEXT.md → no dep constraints, normal sequential behavior
console.log('\n=== absent-context ===');
{
test('absent-context', async () => {
const base = createFixtureBase();
try {
// M001: incomplete, no CONTEXT.md
@ -229,19 +224,18 @@ async function main(): Promise<void> {
const state = await deriveState(base);
assertEq(state.registry[0]?.status, 'active', 'absent-context: M001 is active');
assertEq(state.registry[1]?.status, 'pending', 'absent-context: M002 is pending');
assertEq(state.activeMilestone?.id, 'M001', 'absent-context: activeMilestone is M001');
assertTrue(state.phase !== 'blocked', 'absent-context: phase is not blocked');
assert.deepStrictEqual(state.registry[0]?.status, 'active', 'absent-context: M001 is active');
assert.deepStrictEqual(state.registry[1]?.status, 'pending', 'absent-context: M002 is pending');
assert.deepStrictEqual(state.activeMilestone?.id, 'M001', 'absent-context: activeMilestone is M001');
assert.ok(state.phase !== 'blocked', 'absent-context: phase is not blocked');
} finally {
cleanup(base);
}
}
});
// ─── Test Group 5: forward-dep ─────────────────────────────────────────
// M001 depends_on M002, but M002 is already complete → M001 can activate
console.log('\n=== forward-dep ===');
{
test('forward-dep', async () => {
const base = createFixtureBase();
try {
// M001: depends on M002, but M002 is complete so M001 is unblocked
@ -271,18 +265,17 @@ async function main(): Promise<void> {
const state = await deriveState(base);
assertEq(state.activeMilestone?.id, 'M001', 'forward-dep: activeMilestone is M001');
assertEq(state.registry[1]?.status, 'complete', 'forward-dep: M002 is complete');
assertTrue(state.phase !== 'blocked', 'forward-dep: phase is not blocked');
assert.deepStrictEqual(state.activeMilestone?.id, 'M001', 'forward-dep: activeMilestone is M001');
assert.deepStrictEqual(state.registry[1]?.status, 'complete', 'forward-dep: M002 is complete');
assert.ok(state.phase !== 'blocked', 'forward-dep: phase is not blocked');
} finally {
cleanup(base);
}
}
});
// ─── Test Group 6: empty-deps-list ─────────────────────────────────────
// M002 has `depends_on: []` — empty list means no constraint, normal sequential behavior
console.log('\n=== empty-deps-list ===');
{
test('empty-deps-list', async () => {
const base = createFixtureBase();
try {
// M001: incomplete, no context
@ -310,20 +303,19 @@ async function main(): Promise<void> {
const state = await deriveState(base);
assertEq(state.registry[0]?.status, 'active', 'empty-deps-list: M001 is active');
assertEq(state.registry[1]?.status, 'pending', 'empty-deps-list: M002 is pending (M001 not done yet)');
assertTrue(state.phase !== 'blocked', 'empty-deps-list: phase is not blocked');
assert.deepStrictEqual(state.registry[0]?.status, 'active', 'empty-deps-list: M001 is active');
assert.deepStrictEqual(state.registry[1]?.status, 'pending', 'empty-deps-list: M002 is pending (M001 not done yet)');
assert.ok(state.phase !== 'blocked', 'empty-deps-list: phase is not blocked');
} finally {
cleanup(base);
}
}
});
// ─── Test Group 7: unique-id-deps ──────────────────────────────────────
// M004-0zjrg0 is complete, M005-b0m2hl depends_on M004-0zjrg0 → M005 should activate.
// Regression: parseContextDependsOn() used .toUpperCase(), converting "M004-0zjrg0"
// to "M004-0ZJRG0", breaking the case-sensitive lookup in completeMilestoneIds.
console.log('\n=== unique-id-deps: unique milestone IDs with lowercase hex suffix ===');
{
test('unique-id-deps: unique milestone IDs with lowercase hex suffix', async () => {
const base = createFixtureBase();
try {
// M004-0zjrg0: complete (all slices done + SUMMARY present)
@ -344,23 +336,22 @@ async function main(): Promise<void> {
const state = await deriveState(base);
assertEq(state.registry.find(e => e.id === 'M004-0zjrg0')?.status, 'complete',
assert.deepStrictEqual(state.registry.find(e => e.id === 'M004-0zjrg0')?.status, 'complete',
'unique-id-deps: M004-0zjrg0 is complete');
assertEq(state.registry.find(e => e.id === 'M005-b0m2hl')?.status, 'active',
assert.deepStrictEqual(state.registry.find(e => e.id === 'M005-b0m2hl')?.status, 'active',
'unique-id-deps: M005-b0m2hl is active (dep on M004-0zjrg0 met)');
assertEq(state.activeMilestone?.id, 'M005-b0m2hl',
assert.deepStrictEqual(state.activeMilestone?.id, 'M005-b0m2hl',
'unique-id-deps: activeMilestone is M005-b0m2hl');
assertTrue(state.phase !== 'blocked',
assert.ok(state.phase !== 'blocked',
'unique-id-deps: phase is not blocked');
} finally {
cleanup(base);
}
}
});
// ─── Test Group 8: unique-id-deps-blocked ─────────────────────────────
// M004-0zjrg0 is NOT complete, M005-b0m2hl depends_on M004-0zjrg0 → M005 should be pending
console.log('\n=== unique-id-deps-blocked: unique ID dep not yet met ===');
{
test('unique-id-deps-blocked: unique ID dep not yet met', async () => {
const base = createFixtureBase();
try {
// M004-0zjrg0: incomplete (slice not done)
@ -388,20 +379,19 @@ async function main(): Promise<void> {
const state = await deriveState(base);
assertEq(state.activeMilestone?.id, 'M004-0zjrg0',
assert.deepStrictEqual(state.activeMilestone?.id, 'M004-0zjrg0',
'unique-id-deps-blocked: activeMilestone is M004-0zjrg0');
assertEq(state.registry.find(e => e.id === 'M005-b0m2hl')?.status, 'pending',
assert.deepStrictEqual(state.registry.find(e => e.id === 'M005-b0m2hl')?.status, 'pending',
'unique-id-deps-blocked: M005-b0m2hl is pending (dep not met)');
} finally {
cleanup(base);
}
}
});
// ─── Test Group 9: draft-context-deps ────────────────────────────────
// M001 is incomplete, M002 has only CONTEXT-DRAFT.md (no CONTEXT.md) with
// depends_on: [M001] → M002 should remain pending, not be promoted to active.
console.log('\n=== draft-context-deps: depends_on read from CONTEXT-DRAFT.md ===');
{
test('draft-context-deps: depends_on read from CONTEXT-DRAFT.md', async () => {
const base = createFixtureBase();
try {
// M001: incomplete (one slice, no SUMMARY)
@ -439,18 +429,17 @@ async function main(): Promise<void> {
const state = await deriveState(base);
assertEq(state.registry[0]?.status, 'active', 'draft-context-deps: M001 is active');
assertEq(state.registry[1]?.status, 'pending', 'draft-context-deps: M002 is pending (dep-blocked via draft)');
assertEq(state.activeMilestone?.id, 'M001', 'draft-context-deps: activeMilestone is M001');
assert.deepStrictEqual(state.registry[0]?.status, 'active', 'draft-context-deps: M001 is active');
assert.deepStrictEqual(state.registry[1]?.status, 'pending', 'draft-context-deps: M002 is pending (dep-blocked via draft)');
assert.deepStrictEqual(state.activeMilestone?.id, 'M001', 'draft-context-deps: activeMilestone is M001');
} finally {
cleanup(base);
}
}
});
// ─── Test Group 10: draft-context-deps-no-roadmap ──────────────────────
// Same as above but without roadmaps — milestones discovered from directory only.
console.log('\n=== draft-context-deps-no-roadmap: depends_on from draft without roadmap ===');
{
test('draft-context-deps-no-roadmap: depends_on from draft without roadmap', async () => {
const base = createFixtureBase();
try {
// M001: exists as directory only (no roadmap, no summary)
@ -463,40 +452,38 @@ async function main(): Promise<void> {
const state = await deriveState(base);
const m002Entry = state.registry.find(e => e.id === 'M002');
assertEq(m002Entry?.status, 'pending', 'draft-no-roadmap: M002 is pending (dep-blocked via draft)');
assert.deepStrictEqual(m002Entry?.status, 'pending', 'draft-no-roadmap: M002 is pending (dep-blocked via draft)');
} finally {
cleanup(base);
}
}
});
// ─── Test Group 11: parseContextDependsOn preserves case ──────────────
// Direct unit test: verify the parsed dep ID matches the input exactly
console.log('\n=== parseContextDependsOn: preserves case of unique IDs ===');
{
test('parseContextDependsOn: preserves case of unique IDs', async () => {
const { parseContextDependsOn } = await import('../files.ts');
const deps1 = parseContextDependsOn('---\ndepends_on: [M004-0zjrg0]\n---\n');
assertEq(deps1[0], 'M004-0zjrg0',
assert.deepStrictEqual(deps1[0], 'M004-0zjrg0',
'parseContextDependsOn preserves lowercase hex suffix');
const deps2 = parseContextDependsOn('---\ndepends_on: [M001, M004-abc123]\n---\n');
assertEq(deps2[0], 'M001', 'preserves classic uppercase ID');
assertEq(deps2[1], 'M004-abc123', 'preserves mixed-case unique ID');
assert.deepStrictEqual(deps2[0], 'M001', 'preserves classic uppercase ID');
assert.deepStrictEqual(deps2[1], 'M004-abc123', 'preserves mixed-case unique ID');
const deps3 = parseContextDependsOn('---\ndepends_on: []\n---\n');
assertEq(deps3.length, 0, 'empty deps returns empty array');
assert.deepStrictEqual(deps3.length, 0, 'empty deps returns empty array');
const deps4 = parseContextDependsOn(null);
assertEq(deps4.length, 0, 'null content returns empty array');
}
assert.deepStrictEqual(deps4.length, 0, 'null content returns empty array');
});
// ─── Test Group 10: draft-only-deps-blocked (#1724) ────────────────────
// M002 has only CONTEXT-DRAFT.md (no CONTEXT.md) with depends_on: [M001].
// M001 is incomplete → M002 must remain pending, not get promoted to active.
// Regression: before #1724, parseContextDependsOn received null for draft-only
// milestones, returning [], which caused dep-blocked milestones to be promoted.
console.log('\n=== draft-only-deps-blocked: CONTEXT-DRAFT.md depends_on blocks promotion ===');
{
test('draft-only-deps-blocked: CONTEXT-DRAFT.md depends_on blocks promotion', async () => {
const base = createFixtureBase();
try {
// M001: incomplete (one slice, no SUMMARY)
@ -525,22 +512,21 @@ async function main(): Promise<void> {
const state = await deriveState(base);
assertEq(state.activeMilestone?.id, 'M001',
assert.deepStrictEqual(state.activeMilestone?.id, 'M001',
'draft-only-deps-blocked: activeMilestone is M001');
assertEq(state.registry.find(e => e.id === 'M002')?.status, 'pending',
assert.deepStrictEqual(state.registry.find(e => e.id === 'M002')?.status, 'pending',
'draft-only-deps-blocked: M002 is pending (dep on M001 not met, read from CONTEXT-DRAFT)');
assertTrue(state.phase !== 'blocked',
assert.ok(state.phase !== 'blocked',
'draft-only-deps-blocked: phase is not blocked (M001 is active)');
} finally {
cleanup(base);
}
}
});
// ─── Test Group 11: draft-only-deps-unblocked (#1724) ─────────────────
// M001 is complete, M002 has only CONTEXT-DRAFT.md with depends_on: [M001].
// M002 should become active because its dep is satisfied.
console.log('\n=== draft-only-deps-unblocked: CONTEXT-DRAFT.md dep met → milestone activates ===');
{
test('draft-only-deps-unblocked: CONTEXT-DRAFT.md dep met → milestone activates', async () => {
const base = createFixtureBase();
try {
// M001: complete
@ -561,22 +547,21 @@ async function main(): Promise<void> {
const state = await deriveState(base);
assertEq(state.registry.find(e => e.id === 'M001')?.status, 'complete',
assert.deepStrictEqual(state.registry.find(e => e.id === 'M001')?.status, 'complete',
'draft-only-deps-unblocked: M001 is complete');
assertEq(state.registry.find(e => e.id === 'M002')?.status, 'active',
assert.deepStrictEqual(state.registry.find(e => e.id === 'M002')?.status, 'active',
'draft-only-deps-unblocked: M002 is active (dep on M001 met via CONTEXT-DRAFT)');
assertEq(state.activeMilestone?.id, 'M002',
assert.deepStrictEqual(state.activeMilestone?.id, 'M002',
'draft-only-deps-unblocked: activeMilestone is M002');
} finally {
cleanup(base);
}
}
});
// ─── Test Group 12: draft-only-deps-with-roadmap (#1724) ──────────────
// M002 has a roadmap + only CONTEXT-DRAFT.md with depends_on: [M001].
// Tests the has-roadmap code path (second occurrence of the fix).
console.log('\n=== draft-only-deps-with-roadmap: has-roadmap path reads CONTEXT-DRAFT deps ===');
{
test('draft-only-deps-with-roadmap: has-roadmap path reads CONTEXT-DRAFT deps', async () => {
const base = createFixtureBase();
try {
// M001: incomplete
@ -614,20 +599,19 @@ async function main(): Promise<void> {
const state = await deriveState(base);
assertEq(state.activeMilestone?.id, 'M001',
assert.deepStrictEqual(state.activeMilestone?.id, 'M001',
'draft-only-deps-with-roadmap: activeMilestone is M001');
assertEq(state.registry.find(e => e.id === 'M002')?.status, 'pending',
assert.deepStrictEqual(state.registry.find(e => e.id === 'M002')?.status, 'pending',
'draft-only-deps-with-roadmap: M002 is pending (dep read from CONTEXT-DRAFT in has-roadmap path)');
} finally {
cleanup(base);
}
}
});
// ─── Test Group 13: draft-only-no-deps (#1724) ────────────────────────
// M002 has only CONTEXT-DRAFT.md with NO depends_on field.
// Should behave same as no context file — normal sequential behavior.
console.log('\n=== draft-only-no-deps: CONTEXT-DRAFT without depends_on → no constraint ===');
{
test('draft-only-no-deps: CONTEXT-DRAFT without depends_on → no constraint', async () => {
const base = createFixtureBase();
try {
// M001: complete
@ -648,17 +632,10 @@ async function main(): Promise<void> {
const state = await deriveState(base);
assertEq(state.registry.find(e => e.id === 'M002')?.status, 'active',
assert.deepStrictEqual(state.registry.find(e => e.id === 'M002')?.status, 'active',
'draft-only-no-deps: M002 is active (no deps constraint in draft)');
} finally {
cleanup(base);
}
}
report();
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
});

View file

@ -1,11 +1,10 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { deriveState, isSliceComplete, isMilestoneComplete, isGhostMilestone } from '../state.ts';
import { createTestContext } from './test-helpers.ts';
const { assertEq, assertTrue, report } = createTestContext();
// ─── Fixture Helpers ───────────────────────────────────────────────────────
function createFixtureBase(): string {
@ -65,30 +64,28 @@ function cleanup(base: string): void {
// Test Groups
// ═══════════════════════════════════════════════════════════════════════════
async function main(): Promise<void> {
describe('derive-state', async () => {
// ─── Test 1: empty milestones dir → pre-planning ───────────────────────
console.log('\n=== empty milestones dir → pre-planning ===');
{
test('empty milestones dir → pre-planning', async () => {
const base = createFixtureBase();
try {
const state = await deriveState(base);
assertEq(state.phase, 'pre-planning', 'phase is pre-planning');
assertEq(state.activeMilestone, null, 'activeMilestone is null');
assertEq(state.activeSlice, null, 'activeSlice is null');
assertEq(state.activeTask, null, 'activeTask is null');
assertEq(state.registry, [], 'registry is empty');
assertEq(state.progress?.milestones?.done, 0, 'milestones done = 0');
assertEq(state.progress?.milestones?.total, 0, 'milestones total = 0');
assert.deepStrictEqual(state.phase, 'pre-planning', 'phase is pre-planning');
assert.deepStrictEqual(state.activeMilestone, null, 'activeMilestone is null');
assert.deepStrictEqual(state.activeSlice, null, 'activeSlice is null');
assert.deepStrictEqual(state.activeTask, null, 'activeTask is null');
assert.deepStrictEqual(state.registry, [], 'registry is empty');
assert.deepStrictEqual(state.progress?.milestones?.done, 0, 'milestones done = 0');
assert.deepStrictEqual(state.progress?.milestones?.total, 0, 'milestones total = 0');
} finally {
cleanup(base);
}
}
});
// ─── Test 2: milestone dir exists but no roadmap → pre-planning ────────
console.log('\n=== milestone dir exists but no roadmap → pre-planning ===');
{
test('milestone dir exists but no roadmap → pre-planning', async () => {
const base = createFixtureBase();
try {
// Create M001 directory with CONTEXT but no roadmap file
@ -97,21 +94,20 @@ async function main(): Promise<void> {
const state = await deriveState(base);
assertEq(state.phase, 'pre-planning', 'phase is pre-planning');
assertTrue(state.activeMilestone !== null, 'activeMilestone is not null');
assertEq(state.activeMilestone?.id, 'M001', 'activeMilestone id is M001');
assertEq(state.activeSlice, null, 'activeSlice is null');
assertEq(state.activeTask, null, 'activeTask is null');
assertEq(state.registry.length, 1, 'registry has 1 entry');
assertEq(state.registry[0]?.status, 'active', 'registry entry status is active');
assert.deepStrictEqual(state.phase, 'pre-planning', 'phase is pre-planning');
assert.ok(state.activeMilestone !== null, 'activeMilestone is not null');
assert.deepStrictEqual(state.activeMilestone?.id, 'M001', 'activeMilestone id is M001');
assert.deepStrictEqual(state.activeSlice, null, 'activeSlice is null');
assert.deepStrictEqual(state.activeTask, null, 'activeTask is null');
assert.deepStrictEqual(state.registry.length, 1, 'registry has 1 entry');
assert.deepStrictEqual(state.registry[0]?.status, 'active', 'registry entry status is active');
} finally {
cleanup(base);
}
}
});
// ─── Test 3: roadmap with incomplete slice, no plan → planning ─────────
console.log('\n=== roadmap with incomplete slice, no plan → planning ===');
{
test('roadmap with incomplete slice, no plan → planning', async () => {
const base = createFixtureBase();
try {
writeRoadmap(base, 'M001', `# M001: Test Milestone
@ -126,20 +122,19 @@ async function main(): Promise<void> {
const state = await deriveState(base);
assertEq(state.phase, 'planning', 'phase is planning');
assertTrue(state.activeSlice !== null, 'activeSlice is not null');
assertEq(state.activeSlice?.id, 'S01', 'activeSlice id is S01');
assertEq(state.activeTask, null, 'activeTask is null');
assertEq(state.progress?.slices?.done, 0, 'slices done = 0');
assertEq(state.progress?.slices?.total, 1, 'slices total = 1');
assert.deepStrictEqual(state.phase, 'planning', 'phase is planning');
assert.ok(state.activeSlice !== null, 'activeSlice is not null');
assert.deepStrictEqual(state.activeSlice?.id, 'S01', 'activeSlice id is S01');
assert.deepStrictEqual(state.activeTask, null, 'activeTask is null');
assert.deepStrictEqual(state.progress?.slices?.done, 0, 'slices done = 0');
assert.deepStrictEqual(state.progress?.slices?.total, 1, 'slices total = 1');
} finally {
cleanup(base);
}
}
});
// ─── Test 4: roadmap + plan with incomplete tasks → executing ──────────
console.log('\n=== roadmap + plan with incomplete tasks → executing ===');
{
test('roadmap + plan with incomplete tasks → executing', async () => {
const base = createFixtureBase();
try {
writeRoadmap(base, 'M001', `# M001: Test Milestone
@ -168,19 +163,18 @@ async function main(): Promise<void> {
const state = await deriveState(base);
assertEq(state.phase, 'executing', 'phase is executing');
assertTrue(state.activeTask !== null, 'activeTask is not null');
assertEq(state.activeTask?.id, 'T01', 'activeTask id is T01');
assertEq(state.progress?.tasks?.done, 0, 'tasks done = 0');
assertEq(state.progress?.tasks?.total, 2, 'tasks total = 2');
assert.deepStrictEqual(state.phase, 'executing', 'phase is executing');
assert.ok(state.activeTask !== null, 'activeTask is not null');
assert.deepStrictEqual(state.activeTask?.id, 'T01', 'activeTask id is T01');
assert.deepStrictEqual(state.progress?.tasks?.done, 0, 'tasks done = 0');
assert.deepStrictEqual(state.progress?.tasks?.total, 2, 'tasks total = 2');
} finally {
cleanup(base);
}
}
});
// ─── Test 5: executing + continue file → resume message ─────────────
console.log('\n=== executing + continue file → resume message ===');
{
test('executing + continue file → resume message', async () => {
const base = createFixtureBase();
try {
writeRoadmap(base, 'M001', `# M001: Test Milestone
@ -228,21 +222,20 @@ Continue from step 2.
const state = await deriveState(base);
assertEq(state.phase, 'executing', 'interrupted: phase is executing');
assertTrue(state.activeTask !== null, 'interrupted: activeTask is not null');
assertEq(state.activeTask?.id, 'T01', 'interrupted: activeTask id is T01');
assertTrue(
assert.deepStrictEqual(state.phase, 'executing', 'interrupted: phase is executing');
assert.ok(state.activeTask !== null, 'interrupted: activeTask is not null');
assert.deepStrictEqual(state.activeTask?.id, 'T01', 'interrupted: activeTask id is T01');
assert.ok(
state.nextAction.includes('Resume') || state.nextAction.includes('resume') || state.nextAction.includes('continue.md'),
'interrupted: nextAction mentions Resume/resume/continue.md'
);
} finally {
cleanup(base);
}
}
});
// ─── Test 6: all tasks done, slice not [x] → summarizing ──────────────
console.log('\n=== all tasks done, slice not [x] → summarizing ===');
{
test('all tasks done, slice not [x] → summarizing', async () => {
const base = createFixtureBase();
try {
writeRoadmap(base, 'M001', `# M001: Test Milestone
@ -271,24 +264,23 @@ Continue from step 2.
const state = await deriveState(base);
assertEq(state.phase, 'summarizing', 'summarizing: phase is summarizing');
assertTrue(state.activeSlice !== null, 'summarizing: activeSlice is not null');
assertEq(state.activeSlice?.id, 'S01', 'summarizing: activeSlice id is S01');
assertEq(state.activeTask, null, 'summarizing: activeTask is null');
assertTrue(
assert.deepStrictEqual(state.phase, 'summarizing', 'summarizing: phase is summarizing');
assert.ok(state.activeSlice !== null, 'summarizing: activeSlice is not null');
assert.deepStrictEqual(state.activeSlice?.id, 'S01', 'summarizing: activeSlice id is S01');
assert.deepStrictEqual(state.activeTask, null, 'summarizing: activeTask is null');
assert.ok(
state.nextAction.toLowerCase().includes('summary') || state.nextAction.toLowerCase().includes('complete'),
'summarizing: nextAction mentions summary or complete'
);
assertEq(state.progress?.tasks?.done, 2, 'summarizing: tasks done = 2');
assertEq(state.progress?.tasks?.total, 2, 'summarizing: tasks total = 2');
assert.deepStrictEqual(state.progress?.tasks?.done, 2, 'summarizing: tasks done = 2');
assert.deepStrictEqual(state.progress?.tasks?.total, 2, 'summarizing: tasks total = 2');
} finally {
cleanup(base);
}
}
});
// ─── Test 7: all milestones complete → complete ────────────────────────
console.log('\n=== all milestones complete → complete ===');
{
test('all milestones complete → complete', async () => {
const base = createFixtureBase();
try {
writeRoadmap(base, 'M001', `# M001: Test Milestone
@ -306,23 +298,22 @@ Continue from step 2.
const state = await deriveState(base);
assertEq(state.phase, 'complete', 'complete: phase is complete');
assertEq(state.activeSlice, null, 'complete: activeSlice is null');
assertEq(state.activeTask, null, 'complete: activeTask is null');
assertTrue(
assert.deepStrictEqual(state.phase, 'complete', 'complete: phase is complete');
assert.deepStrictEqual(state.activeSlice, null, 'complete: activeSlice is null');
assert.deepStrictEqual(state.activeTask, null, 'complete: activeTask is null');
assert.ok(
state.nextAction.toLowerCase().includes('complete'),
'complete: nextAction mentions complete'
);
assertEq(state.registry.length, 1, 'complete: registry has 1 entry');
assertEq(state.registry[0]?.status, 'complete', 'complete: registry[0] status is complete');
assert.deepStrictEqual(state.registry.length, 1, 'complete: registry has 1 entry');
assert.deepStrictEqual(state.registry[0]?.status, 'complete', 'complete: registry[0] status is complete');
} finally {
cleanup(base);
}
}
});
// ─── Test 7b: complete with active requirements → surfaces unmapped reqs ──
console.log('\n=== complete with active requirements → surfaces unmapped reqs ===');
{
test('complete with active requirements → surfaces unmapped reqs', async () => {
const base = createFixtureBase();
try {
writeRoadmap(base, 'M001', `# M001: Test Milestone
@ -355,23 +346,22 @@ Continue from step 2.
const state = await deriveState(base);
assertEq(state.phase, 'complete', 'complete-with-reqs: phase is complete');
assertTrue(
assert.deepStrictEqual(state.phase, 'complete', 'complete-with-reqs: phase is complete');
assert.ok(
state.nextAction.includes('2 active requirements'),
'complete-with-reqs: nextAction mentions 2 active requirements'
);
assertTrue(
assert.ok(
state.nextAction.includes('REQUIREMENTS.md'),
'complete-with-reqs: nextAction mentions REQUIREMENTS.md'
);
} finally {
cleanup(base);
}
}
});
// ─── Test 7c: complete with no active requirements → standard message ──
console.log('\n=== complete with no active requirements → standard message ===');
{
test('complete with no active requirements → standard message', async () => {
const base = createFixtureBase();
try {
writeRoadmap(base, 'M001', `# M001: Test Milestone
@ -396,16 +386,15 @@ Continue from step 2.
const state = await deriveState(base);
assertEq(state.phase, 'complete', 'complete-no-active-reqs: phase is complete');
assertEq(state.nextAction, 'All milestones complete.', 'complete-no-active-reqs: standard completion message');
assert.deepStrictEqual(state.phase, 'complete', 'complete-no-active-reqs: phase is complete');
assert.deepStrictEqual(state.nextAction, 'All milestones complete.', 'complete-no-active-reqs: standard completion message');
} finally {
cleanup(base);
}
}
});
// ─── Test 8: blocked dependencies ──────────────────────────────────────
console.log('\n=== blocked dependencies ===');
{
test('blocked dependencies', async () => {
// Case A: S01 active (deps satisfied), S02 blocked on S01
const base1 = createFixtureBase();
try {
@ -436,8 +425,8 @@ Continue from step 2.
const state1 = await deriveState(base1);
assertEq(state1.phase, 'executing', 'blocked-A: phase is executing (S01 active)');
assertEq(state1.activeSlice?.id, 'S01', 'blocked-A: activeSlice is S01');
assert.deepStrictEqual(state1.phase, 'executing', 'blocked-A: phase is executing (S01 active)');
assert.deepStrictEqual(state1.activeSlice?.id, 'S01', 'blocked-A: activeSlice is S01');
} finally {
cleanup(base1);
}
@ -457,17 +446,16 @@ Continue from step 2.
const state2 = await deriveState(base2);
assertEq(state2.phase, 'blocked', 'blocked-B: phase is blocked');
assertEq(state2.activeSlice, null, 'blocked-B: activeSlice is null');
assertTrue(state2.blockers.length > 0, 'blocked-B: blockers array is non-empty');
assert.deepStrictEqual(state2.phase, 'blocked', 'blocked-B: phase is blocked');
assert.deepStrictEqual(state2.activeSlice, null, 'blocked-B: activeSlice is null');
assert.ok(state2.blockers.length > 0, 'blocked-B: blockers array is non-empty');
} finally {
cleanup(base2);
}
}
});
// ─── Test 9: multi-milestone registry ──────────────────────────────────
console.log('\n=== multi-milestone registry ===');
{
test('multi-milestone registry', async () => {
const base = createFixtureBase();
try {
// M001: complete (all slices done)
@ -501,24 +489,23 @@ Continue from step 2.
const state = await deriveState(base);
assertEq(state.registry.length, 3, 'multi-ms: registry has 3 entries');
assertEq(state.registry[0]?.id, 'M001', 'multi-ms: registry[0] is M001');
assertEq(state.registry[0]?.status, 'complete', 'multi-ms: M001 is complete');
assertEq(state.registry[1]?.id, 'M002', 'multi-ms: registry[1] is M002');
assertEq(state.registry[1]?.status, 'active', 'multi-ms: M002 is active');
assertEq(state.registry[2]?.id, 'M003', 'multi-ms: registry[2] is M003');
assertEq(state.registry[2]?.status, 'pending', 'multi-ms: M003 is pending');
assertEq(state.activeMilestone?.id, 'M002', 'multi-ms: activeMilestone is M002');
assertEq(state.progress?.milestones?.done, 1, 'multi-ms: milestones done = 1');
assertEq(state.progress?.milestones?.total, 3, 'multi-ms: milestones total = 3');
assert.deepStrictEqual(state.registry.length, 3, 'multi-ms: registry has 3 entries');
assert.deepStrictEqual(state.registry[0]?.id, 'M001', 'multi-ms: registry[0] is M001');
assert.deepStrictEqual(state.registry[0]?.status, 'complete', 'multi-ms: M001 is complete');
assert.deepStrictEqual(state.registry[1]?.id, 'M002', 'multi-ms: registry[1] is M002');
assert.deepStrictEqual(state.registry[1]?.status, 'active', 'multi-ms: M002 is active');
assert.deepStrictEqual(state.registry[2]?.id, 'M003', 'multi-ms: registry[2] is M003');
assert.deepStrictEqual(state.registry[2]?.status, 'pending', 'multi-ms: M003 is pending');
assert.deepStrictEqual(state.activeMilestone?.id, 'M002', 'multi-ms: activeMilestone is M002');
assert.deepStrictEqual(state.progress?.milestones?.done, 1, 'multi-ms: milestones done = 1');
assert.deepStrictEqual(state.progress?.milestones?.total, 3, 'multi-ms: milestones total = 3');
} finally {
cleanup(base);
}
}
});
// ─── Test 10: requirements integration ─────────────────────────────────
console.log('\n=== requirements integration ===');
{
test('requirements integration', async () => {
const base = createFixtureBase();
try {
writeRequirements(base, `# Requirements
@ -559,20 +546,19 @@ Continue from step 2.
// Need at least an empty milestones dir for deriveState
const state = await deriveState(base);
assertTrue(state.requirements !== undefined, 'requirements: requirements object exists');
assertEq(state.requirements?.active, 2, 'requirements: active = 2');
assertEq(state.requirements?.validated, 1, 'requirements: validated = 1');
assertEq(state.requirements?.deferred, 2, 'requirements: deferred = 2');
assertEq(state.requirements?.outOfScope, 1, 'requirements: outOfScope = 1');
assertEq(state.requirements?.total, 6, 'requirements: total = 6 (sum of all)');
assert.ok(state.requirements !== undefined, 'requirements: requirements object exists');
assert.deepStrictEqual(state.requirements?.active, 2, 'requirements: active = 2');
assert.deepStrictEqual(state.requirements?.validated, 1, 'requirements: validated = 1');
assert.deepStrictEqual(state.requirements?.deferred, 2, 'requirements: deferred = 2');
assert.deepStrictEqual(state.requirements?.outOfScope, 1, 'requirements: outOfScope = 1');
assert.deepStrictEqual(state.requirements?.total, 6, 'requirements: total = 6 (sum of all)');
} finally {
cleanup(base);
}
}
});
// ─── Test 11: all slices [x], no summary → completing-milestone ────────
console.log('\n=== all slices [x], no summary → completing-milestone ===');
{
test('all slices [x], no summary → completing-milestone', async () => {
const base = createFixtureBase();
try {
writeRoadmap(base, 'M001', `# M001: Test Milestone
@ -592,27 +578,26 @@ Continue from step 2.
const state = await deriveState(base);
assertEq(state.phase, 'completing-milestone', 'completing-ms: phase is completing-milestone');
assertTrue(state.activeMilestone !== null, 'completing-ms: activeMilestone is not null');
assertEq(state.activeMilestone?.id, 'M001', 'completing-ms: activeMilestone id is M001');
assertEq(state.activeSlice, null, 'completing-ms: activeSlice is null');
assertEq(state.activeTask, null, 'completing-ms: activeTask is null');
assertEq(state.registry.length, 1, 'completing-ms: registry has 1 entry');
assertEq(state.registry[0]?.status, 'active', 'completing-ms: registry[0] status is active (not complete)');
assertEq(state.progress?.slices?.done, 2, 'completing-ms: slices done = 2');
assertEq(state.progress?.slices?.total, 2, 'completing-ms: slices total = 2');
assertTrue(
assert.deepStrictEqual(state.phase, 'completing-milestone', 'completing-ms: phase is completing-milestone');
assert.ok(state.activeMilestone !== null, 'completing-ms: activeMilestone is not null');
assert.deepStrictEqual(state.activeMilestone?.id, 'M001', 'completing-ms: activeMilestone id is M001');
assert.deepStrictEqual(state.activeSlice, null, 'completing-ms: activeSlice is null');
assert.deepStrictEqual(state.activeTask, null, 'completing-ms: activeTask is null');
assert.deepStrictEqual(state.registry.length, 1, 'completing-ms: registry has 1 entry');
assert.deepStrictEqual(state.registry[0]?.status, 'active', 'completing-ms: registry[0] status is active (not complete)');
assert.deepStrictEqual(state.progress?.slices?.done, 2, 'completing-ms: slices done = 2');
assert.deepStrictEqual(state.progress?.slices?.total, 2, 'completing-ms: slices total = 2');
assert.ok(
state.nextAction.toLowerCase().includes('summary') || state.nextAction.toLowerCase().includes('complete'),
'completing-ms: nextAction mentions summary or complete'
);
} finally {
cleanup(base);
}
}
});
// ─── Test 12: all slices [x], summary exists → complete ───────────────
console.log('\n=== all slices [x], summary exists → complete ===');
{
test('all slices [x], summary exists → complete', async () => {
const base = createFixtureBase();
try {
writeRoadmap(base, 'M001', `# M001: Test Milestone
@ -630,19 +615,18 @@ Continue from step 2.
const state = await deriveState(base);
assertEq(state.phase, 'complete', 'summary-exists: phase is complete');
assertEq(state.registry.length, 1, 'summary-exists: registry has 1 entry');
assertEq(state.registry[0]?.status, 'complete', 'summary-exists: registry[0] status is complete');
assertEq(state.activeSlice, null, 'summary-exists: activeSlice is null');
assertEq(state.activeTask, null, 'summary-exists: activeTask is null');
assert.deepStrictEqual(state.phase, 'complete', 'summary-exists: phase is complete');
assert.deepStrictEqual(state.registry.length, 1, 'summary-exists: registry has 1 entry');
assert.deepStrictEqual(state.registry[0]?.status, 'complete', 'summary-exists: registry[0] status is complete');
assert.deepStrictEqual(state.activeSlice, null, 'summary-exists: activeSlice is null');
assert.deepStrictEqual(state.activeTask, null, 'summary-exists: activeTask is null');
} finally {
cleanup(base);
}
}
});
// ─── Test 13: multi-milestone completing-milestone ─────────────────────
console.log('\n=== multi-milestone completing-milestone ===');
{
test('multi-milestone completing-milestone', async () => {
const base = createFixtureBase();
try {
// M001: all slices done + summary exists → complete
@ -687,29 +671,28 @@ Continue from step 2.
const state = await deriveState(base);
assertEq(state.phase, 'completing-milestone', 'multi-completing: phase is completing-milestone');
assertEq(state.activeMilestone?.id, 'M002', 'multi-completing: activeMilestone is M002');
assertEq(state.activeSlice, null, 'multi-completing: activeSlice is null');
assertEq(state.activeTask, null, 'multi-completing: activeTask is null');
assertEq(state.registry.length, 3, 'multi-completing: registry has 3 entries');
assertEq(state.registry[0]?.id, 'M001', 'multi-completing: registry[0] is M001');
assertEq(state.registry[0]?.status, 'complete', 'multi-completing: M001 is complete');
assertEq(state.registry[1]?.id, 'M002', 'multi-completing: registry[1] is M002');
assertEq(state.registry[1]?.status, 'active', 'multi-completing: M002 is active (completing-milestone)');
assertEq(state.registry[2]?.id, 'M003', 'multi-completing: registry[2] is M003');
assertEq(state.registry[2]?.status, 'pending', 'multi-completing: M003 is pending');
assertEq(state.progress?.milestones?.done, 1, 'multi-completing: milestones done = 1');
assertEq(state.progress?.milestones?.total, 3, 'multi-completing: milestones total = 3');
assertEq(state.progress?.slices?.done, 2, 'multi-completing: slices done = 2');
assertEq(state.progress?.slices?.total, 2, 'multi-completing: slices total = 2');
assert.deepStrictEqual(state.phase, 'completing-milestone', 'multi-completing: phase is completing-milestone');
assert.deepStrictEqual(state.activeMilestone?.id, 'M002', 'multi-completing: activeMilestone is M002');
assert.deepStrictEqual(state.activeSlice, null, 'multi-completing: activeSlice is null');
assert.deepStrictEqual(state.activeTask, null, 'multi-completing: activeTask is null');
assert.deepStrictEqual(state.registry.length, 3, 'multi-completing: registry has 3 entries');
assert.deepStrictEqual(state.registry[0]?.id, 'M001', 'multi-completing: registry[0] is M001');
assert.deepStrictEqual(state.registry[0]?.status, 'complete', 'multi-completing: M001 is complete');
assert.deepStrictEqual(state.registry[1]?.id, 'M002', 'multi-completing: registry[1] is M002');
assert.deepStrictEqual(state.registry[1]?.status, 'active', 'multi-completing: M002 is active (completing-milestone)');
assert.deepStrictEqual(state.registry[2]?.id, 'M003', 'multi-completing: registry[2] is M003');
assert.deepStrictEqual(state.registry[2]?.status, 'pending', 'multi-completing: M003 is pending');
assert.deepStrictEqual(state.progress?.milestones?.done, 1, 'multi-completing: milestones done = 1');
assert.deepStrictEqual(state.progress?.milestones?.total, 3, 'multi-completing: milestones total = 3');
assert.deepStrictEqual(state.progress?.slices?.done, 2, 'multi-completing: slices done = 2');
assert.deepStrictEqual(state.progress?.slices?.total, 2, 'multi-completing: slices total = 2');
} finally {
cleanup(base);
}
}
});
// ═══ Milestone with summary but no roadmap → complete ═══════════════════
{
console.log('\n=== milestone with summary and no roadmap → complete ===');
const base = createFixtureBase();
try {
// M001, M002: completed milestones with summaries but no roadmaps
@ -726,17 +709,17 @@ Continue from step 2.
const state = await deriveState(base);
assertEq(state.phase, 'planning', 'summary-no-roadmap: phase is planning (active is M003)');
assertEq(state.activeMilestone?.id, 'M003', 'summary-no-roadmap: active milestone is M003');
assertEq(state.activeMilestone?.title, 'Polish', 'summary-no-roadmap: active title is Polish');
assertEq(state.registry.length, 3, 'summary-no-roadmap: registry has 3 entries');
assertEq(state.registry[0]?.status, 'complete', 'summary-no-roadmap: M001 is complete');
assertEq(state.registry[0]?.title, 'Bootstrap', 'summary-no-roadmap: M001 title from summary');
assertEq(state.registry[1]?.status, 'complete', 'summary-no-roadmap: M002 is complete');
assertEq(state.registry[1]?.title, 'Core Features', 'summary-no-roadmap: M002 title from summary');
assertEq(state.registry[2]?.status, 'active', 'summary-no-roadmap: M003 is active');
assertEq(state.progress?.milestones?.done, 2, 'summary-no-roadmap: milestones done = 2');
assertEq(state.progress?.milestones?.total, 3, 'summary-no-roadmap: milestones total = 3');
assert.deepStrictEqual(state.phase, 'planning', 'summary-no-roadmap: phase is planning (active is M003)');
assert.deepStrictEqual(state.activeMilestone?.id, 'M003', 'summary-no-roadmap: active milestone is M003');
assert.deepStrictEqual(state.activeMilestone?.title, 'Polish', 'summary-no-roadmap: active title is Polish');
assert.deepStrictEqual(state.registry.length, 3, 'summary-no-roadmap: registry has 3 entries');
assert.deepStrictEqual(state.registry[0]?.status, 'complete', 'summary-no-roadmap: M001 is complete');
assert.deepStrictEqual(state.registry[0]?.title, 'Bootstrap', 'summary-no-roadmap: M001 title from summary');
assert.deepStrictEqual(state.registry[1]?.status, 'complete', 'summary-no-roadmap: M002 is complete');
assert.deepStrictEqual(state.registry[1]?.title, 'Core Features', 'summary-no-roadmap: M002 title from summary');
assert.deepStrictEqual(state.registry[2]?.status, 'active', 'summary-no-roadmap: M003 is active');
assert.deepStrictEqual(state.progress?.milestones?.done, 2, 'summary-no-roadmap: milestones done = 2');
assert.deepStrictEqual(state.progress?.milestones?.total, 3, 'summary-no-roadmap: milestones total = 3');
} finally {
cleanup(base);
}
@ -744,7 +727,6 @@ Continue from step 2.
// ═══ All milestones have summary but no roadmap → complete ═════════════
{
console.log('\n=== all milestones summary-only → complete ===');
const base = createFixtureBase();
try {
const m1dir = join(base, '.gsd', 'milestones', 'M001');
@ -752,16 +734,15 @@ Continue from step 2.
writeFileSync(join(m1dir, 'M001-SUMMARY.md'), '---\ntitle: Done\n---\nAll done.');
const state = await deriveState(base);
assertEq(state.phase, 'complete', 'all-summary-only: phase is complete');
assertEq(state.registry[0]?.status, 'complete', 'all-summary-only: M001 is complete');
assert.deepStrictEqual(state.phase, 'complete', 'all-summary-only: phase is complete');
assert.deepStrictEqual(state.registry[0]?.status, 'complete', 'all-summary-only: M001 is complete');
} finally {
cleanup(base);
}
}
// ─── Empty plan (zero tasks) stays in planning, not summarizing (#454) ──
console.log('\n=== empty plan → planning (not summarizing) ===');
{
test('empty plan → planning (not summarizing)', async () => {
const base = createFixtureBase();
try {
writeRoadmap(base, 'M001', `---
@ -786,17 +767,16 @@ slice: S01
## Tasks
`);
const state = await deriveState(base);
assertEq(state.phase, 'planning', 'empty plan stays in planning');
assertEq(state.activeSlice?.id, 'S01', 'active slice is S01');
assertEq(state.activeTask, null, 'no active task');
assert.deepStrictEqual(state.phase, 'planning', 'empty plan stays in planning');
assert.deepStrictEqual(state.activeSlice?.id, 'S01', 'active slice is S01');
assert.deepStrictEqual(state.activeTask, null, 'no active task');
} finally {
cleanup(base);
}
}
});
// ─── Test: completed M001 (summary, no validation) skipped for active M003 (#864) ────
console.log('\n=== completed milestone with summary but no validation is not active (#864) ===');
{
test('completed milestone with summary but no validation is not active (#864)', async () => {
const base = createFixtureBase();
try {
// M001: all slices done, has summary, no validation
@ -806,17 +786,16 @@ slice: S01
writeRoadmap(base, 'M003', `# M003: Active Milestone\n\n**Vision:** Do stuff.\n\n## Slices\n\n- [ ] **S01: Work slice** \`risk:low\` \`depends:[]\`\n > Needs work.\n`);
const state = await deriveState(base);
assertEq(state.activeMilestone?.id, 'M003', 'active milestone is M003, not completed M001');
assert.deepStrictEqual(state.activeMilestone?.id, 'M003', 'active milestone is M003, not completed M001');
const m001Entry = state.registry.find(e => e.id === 'M001');
assertEq(m001Entry?.status, 'complete', 'M001 is marked complete despite no validation');
assert.deepStrictEqual(m001Entry?.status, 'complete', 'M001 is marked complete despite no validation');
} finally {
cleanup(base);
}
}
});
// ─── Test: completed M001 with summary AND validation is complete (#864) ────
console.log('\n=== completed milestone with summary and validation is complete ===');
{
test('completed milestone with summary and validation is complete', async () => {
const base = createFixtureBase();
try {
writeRoadmap(base, 'M001', `# M001: First Milestone\n\n**Vision:** Done.\n\n## Slices\n\n- [x] **S01: Done slice** \`risk:low\` \`depends:[]\`\n > Completed.\n`);
@ -825,32 +804,30 @@ slice: S01
writeRoadmap(base, 'M003', `# M003: Active Milestone\n\n**Vision:** Do stuff.\n\n## Slices\n\n- [ ] **S01: Work slice** \`risk:low\` \`depends:[]\`\n > Needs work.\n`);
const state = await deriveState(base);
assertEq(state.activeMilestone?.id, 'M003', 'active milestone is M003');
assert.deepStrictEqual(state.activeMilestone?.id, 'M003', 'active milestone is M003');
const m001Entry = state.registry.find(e => e.id === 'M001');
assertEq(m001Entry?.status, 'complete', 'M001 with both summary and validation is complete');
assert.deepStrictEqual(m001Entry?.status, 'complete', 'M001 with both summary and validation is complete');
} finally {
cleanup(base);
}
}
});
// ─── Test: all slices done, no summary, no validation → needs validation (#864) ────
console.log('\n=== all slices done, no summary, no validation → validating-milestone ===');
{
test('all slices done, no summary, no validation → validating-milestone', async () => {
const base = createFixtureBase();
try {
writeRoadmap(base, 'M001', `# M001: First Milestone\n\n**Vision:** Validate me.\n\n## Slices\n\n- [x] **S01: Done slice** \`risk:low\` \`depends:[]\`\n > Completed.\n`);
// No summary, no validation — this should be active for validation
const state = await deriveState(base);
assertEq(state.activeMilestone?.id, 'M001', 'M001 is active for validation');
assert.deepStrictEqual(state.activeMilestone?.id, 'M001', 'M001 is active for validation');
} finally {
cleanup(base);
}
}
});
// ─── Test: all slices done, validation pass, no summary → needs completion (#864) ────
console.log('\n=== all slices done, validation pass, no summary → completing-milestone ===');
{
test('all slices done, validation pass, no summary → completing-milestone', async () => {
const base = createFixtureBase();
try {
writeRoadmap(base, 'M001', `# M001: First Milestone\n\n**Vision:** Complete me.\n\n## Slices\n\n- [x] **S01: Done slice** \`risk:low\` \`depends:[]\`\n > Completed.\n`);
@ -858,15 +835,14 @@ slice: S01
// No summary — validated but not yet completed
const state = await deriveState(base);
assertEq(state.activeMilestone?.id, 'M001', 'M001 is active for completion');
assert.deepStrictEqual(state.activeMilestone?.id, 'M001', 'M001 is active for completion');
} finally {
cleanup(base);
}
}
});
// ─── Test: unchecked roadmap slices + summary → complete (summary is terminal) ────
console.log('\n=== unchecked roadmap slices + summary → complete (summary is terminal) ===');
{
test('unchecked roadmap slices + summary → complete (summary is terminal)', async () => {
const base = createFixtureBase();
try {
// M001: roadmap has unchecked slices but a summary exists — should be complete
@ -877,16 +853,15 @@ slice: S01
const state = await deriveState(base);
const m001Entry = state.registry.find(e => e.id === 'M001');
assertEq(m001Entry?.status, 'complete', 'M001 with unchecked roadmap + summary is complete');
assertEq(state.activeMilestone?.id, 'M002', 'active milestone is M002, not M001');
assert.deepStrictEqual(m001Entry?.status, 'complete', 'M001 with unchecked roadmap + summary is complete');
assert.deepStrictEqual(state.activeMilestone?.id, 'M002', 'active milestone is M002, not M001');
} finally {
cleanup(base);
}
}
});
// ─── Test: unchecked roadmap + summary counts toward completeMilestoneIds (deps) ────
console.log('\n=== unchecked roadmap + summary satisfies dependency ===');
{
test('unchecked roadmap + summary satisfies dependency', async () => {
const base = createFixtureBase();
try {
// M001: unchecked roadmap + summary → complete
@ -899,17 +874,16 @@ slice: S01
writeFileSync(join(contextDir, 'M002-CONTEXT.md'), '---\ndepends_on:\n - M001\n---\n\n# M002 Context\n\nDepends on M001.');
const state = await deriveState(base);
assertEq(state.activeMilestone?.id, 'M002', 'M002 is active — M001 dependency satisfied via summary');
assert.deepStrictEqual(state.activeMilestone?.id, 'M002', 'M002 is active — M001 dependency satisfied via summary');
const m002Entry = state.registry.find(e => e.id === 'M002');
assertEq(m002Entry?.status, 'active', 'M002 status is active, not pending');
assert.deepStrictEqual(m002Entry?.status, 'active', 'M002 status is active, not pending');
} finally {
cleanup(base);
}
}
});
// ─── Test: ghost milestone (only META.json) is skipped ───────────────
console.log('\n=== ghost milestone (only META.json) is skipped ===');
{
test('ghost milestone (only META.json) is skipped', async () => {
const base = createFixtureBase();
try {
// Create a ghost milestone directory with only META.json
@ -918,21 +892,20 @@ slice: S01
writeFileSync(join(ghostDir, 'META.json'), JSON.stringify({ id: 'M001' }));
// isGhostMilestone should detect it
assertTrue(isGhostMilestone(base, 'M001'), 'M001 is a ghost milestone');
assert.ok(isGhostMilestone(base, 'M001'), 'M001 is a ghost milestone');
// deriveState should treat this as pre-planning (no real milestones)
const state = await deriveState(base);
assertEq(state.phase, 'pre-planning', 'ghost-only: phase is pre-planning');
assertEq(state.activeMilestone, null, 'ghost-only: no active milestone');
assertEq(state.registry.length, 0, 'ghost-only: registry is empty');
assert.deepStrictEqual(state.phase, 'pre-planning', 'ghost-only: phase is pre-planning');
assert.deepStrictEqual(state.activeMilestone, null, 'ghost-only: no active milestone');
assert.deepStrictEqual(state.registry.length, 0, 'ghost-only: registry is empty');
} finally {
cleanup(base);
}
}
});
// ─── Test: ghost milestone skipped when real milestones exist ──────────
console.log('\n=== ghost milestone skipped alongside real milestones ===');
{
test('ghost milestone skipped alongside real milestones', async () => {
const base = createFixtureBase();
try {
// M001: ghost (only META.json)
@ -946,20 +919,19 @@ slice: S01
writeFileSync(join(realDir, 'M002-CONTEXT.md'), '# Real Milestone\n\nThis has content.');
const state = await deriveState(base);
assertEq(state.activeMilestone?.id, 'M002', 'ghost+real: active milestone is M002');
assert.deepStrictEqual(state.activeMilestone?.id, 'M002', 'ghost+real: active milestone is M002');
// Ghost M001 should not appear in the registry
const m001Entry = state.registry.find(e => e.id === 'M001');
assertEq(m001Entry, undefined, 'ghost+real: M001 not in registry');
assertEq(state.registry.length, 1, 'ghost+real: registry has 1 entry');
assertEq(state.registry[0]?.status, 'active', 'ghost+real: M002 is active');
assert.deepStrictEqual(m001Entry, undefined, 'ghost+real: M001 not in registry');
assert.deepStrictEqual(state.registry.length, 1, 'ghost+real: registry has 1 entry');
assert.deepStrictEqual(state.registry[0]?.status, 'active', 'ghost+real: M002 is active');
} finally {
cleanup(base);
}
}
});
// ─── Test: zero-slice roadmap → pre-planning, not blocked (#1785) ────
console.log('\n=== zero-slice roadmap → pre-planning, not blocked (#1785) ===');
{
test('zero-slice roadmap → pre-planning, not blocked (#1785)', async () => {
const base = createFixtureBase();
try {
// Write a stub roadmap with zero slices (placeholder text, no slice definitions)
@ -967,22 +939,15 @@ slice: S01
const state = await deriveState(base);
assertEq(state.phase, 'pre-planning', 'phase is pre-planning when roadmap has zero slices');
assertTrue(state.activeMilestone !== null, 'activeMilestone is set');
assertEq(state.activeMilestone?.id, 'M001', 'activeMilestone is M001');
assertEq(state.activeSlice, null, 'activeSlice is null');
assertEq(state.activeTask, null, 'activeTask is null');
assertEq(state.blockers.length, 0, 'no blockers reported');
assertTrue(state.nextAction.includes('M001'), 'nextAction references M001');
assert.deepStrictEqual(state.phase, 'pre-planning', 'phase is pre-planning when roadmap has zero slices');
assert.ok(state.activeMilestone !== null, 'activeMilestone is set');
assert.deepStrictEqual(state.activeMilestone?.id, 'M001', 'activeMilestone is M001');
assert.deepStrictEqual(state.activeSlice, null, 'activeSlice is null');
assert.deepStrictEqual(state.activeTask, null, 'activeTask is null');
assert.deepStrictEqual(state.blockers.length, 0, 'no blockers reported');
assert.ok(state.nextAction.includes('M001'), 'nextAction references M001');
} finally {
cleanup(base);
}
}
report();
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
});

View file

@ -1,13 +1,11 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { runGSDDoctor } from "../doctor.js";
import { formatDoctorReportJson } from "../doctor-format.js";
import { createTestContext } from "./test-helpers.ts";
const { assertEq, assertTrue, assertMatch, report } = createTestContext();
// ── Helpers ─────────────────────────────────────────────────────────────────
function makeBase(): { base: string; gsd: string; mDir: string } {
@ -30,41 +28,38 @@ function writeSlice(mDir: string, sliceId: string, planContent: string): string
return sDir;
}
async function main(): Promise<void> {
describe('doctor-enhancements', async () => {
// ── 1. Circular dependency detection ──────────────────────────────────────
console.log("\n=== circular dependency detection ===");
{
test('circular dependency detection', async () => {
const { base, mDir } = makeBase();
writeRoadmap(mDir, `# M001: Circular Test\n\n## Slices\n- [ ] **S01: Slice A** \`risk:low\` \`depends:[S02]\`\n > After this: done\n- [ ] **S02: Slice B** \`risk:low\` \`depends:[S01]\`\n > After this: done\n`);
writeSlice(mDir, "S01", "# S01: Slice A\n\n**Goal:** A\n**Demo:** A\n\n## Tasks\n- [ ] **T01: Task** `est:10m`\n Pending.\n");
writeSlice(mDir, "S02", "# S02: Slice B\n\n**Goal:** B\n**Demo:** B\n\n## Tasks\n- [ ] **T01: Task** `est:10m`\n Pending.\n");
const result = await runGSDDoctor(base, { fix: false });
assertTrue(
assert.ok(
result.issues.some(i => i.code === "circular_slice_dependency"),
"detects circular dependency S01 → S02 → S01",
);
rmSync(base, { recursive: true, force: true });
}
});
// ── 2. Duplicate task IDs ──────────────────────────────────────────────────
console.log("\n=== duplicate task IDs ===");
{
test('duplicate task IDs', async () => {
const { base, mDir } = makeBase();
writeRoadmap(mDir, `# M001: Dup Test\n\n## Slices\n- [ ] **S01: Slice** \`risk:low\` \`depends:[]\`\n > After this: done\n`);
writeSlice(mDir, "S01", "# S01: Slice\n\n**Goal:** G\n**Demo:** D\n\n## Tasks\n- [ ] **T01: First** `est:10m`\n Task one.\n- [ ] **T01: Duplicate** `est:10m`\n Task dup.\n");
const result = await runGSDDoctor(base, { fix: false });
assertTrue(
assert.ok(
result.issues.some(i => i.code === "duplicate_task_id"),
"detects duplicate task ID T01",
);
rmSync(base, { recursive: true, force: true });
}
});
// ── 3. Orphaned slice directory ──────────────────────────────────────────
console.log("\n=== orphaned slice directory ===");
{
test('orphaned slice directory', async () => {
const { base, mDir } = makeBase();
writeRoadmap(mDir, `# M001: Orphan Test\n\n## Slices\n- [ ] **S01: Slice** \`risk:low\` \`depends:[]\`\n > After this: done\n`);
writeSlice(mDir, "S01", "# S01: Slice\n\n**Goal:** G\n**Demo:** D\n\n## Tasks\n- [ ] **T01: Task** `est:10m`\n Pending.\n");
@ -72,16 +67,15 @@ async function main(): Promise<void> {
mkdirSync(join(mDir, "slices", "S99"), { recursive: true });
const result = await runGSDDoctor(base, { fix: false });
assertTrue(
assert.ok(
result.issues.some(i => i.code === "orphaned_slice_directory" && i.message.includes("S99")),
"detects orphaned slice directory S99",
);
rmSync(base, { recursive: true, force: true });
}
});
// ── 4. Task file not in plan ───────────────────────────────────────────────
console.log("\n=== task file not in plan ===");
{
test('task file not in plan', async () => {
const { base, mDir } = makeBase();
writeRoadmap(mDir, `# M001: Extra Task Test\n\n## Slices\n- [ ] **S01: Slice** \`risk:low\` \`depends:[]\`\n > After this: done\n`);
const sDir = writeSlice(mDir, "S01", "# S01: Slice\n\n**Goal:** G\n**Demo:** D\n\n## Tasks\n- [x] **T01: Task** `est:10m`\n Done.\n");
@ -91,16 +85,15 @@ async function main(): Promise<void> {
writeFileSync(join(sDir, "tasks", "T99-SUMMARY.md"), "---\nstatus: done\n---\n# T99\nExtra.\n");
const result = await runGSDDoctor(base, { fix: false });
assertTrue(
assert.ok(
result.issues.some(i => i.code === "task_file_not_in_plan" && i.message.includes("T99")),
"detects task summary T99 not in plan",
);
rmSync(base, { recursive: true, force: true });
}
});
// ── 5. Stale REPLAN file ────────────────────────────────────────────────────
console.log("\n=== stale REPLAN detection ===");
{
test('stale REPLAN detection', async () => {
const { base, mDir } = makeBase();
writeRoadmap(mDir, `# M001: Replan Test\n\n## Slices\n- [ ] **S01: Slice** \`risk:low\` \`depends:[]\`\n > After this: done\n`);
const sDir = writeSlice(mDir, "S01", "# S01: Slice\n\n**Goal:** G\n**Demo:** D\n\n## Tasks\n- [x] **T01: Task** `est:10m`\n Done.\n");
@ -109,16 +102,15 @@ async function main(): Promise<void> {
writeFileSync(join(sDir, "S01-REPLAN.md"), "# S01 REPLAN\nSomething changed.\n");
const result = await runGSDDoctor(base, { fix: false });
assertTrue(
assert.ok(
result.issues.some(i => i.code === "stale_replan_file"),
"detects stale REPLAN when all tasks are done",
);
rmSync(base, { recursive: true, force: true });
}
});
// ── 6. Metrics ledger corrupt ───────────────────────────────────────────────
console.log("\n=== metrics ledger corrupt ===");
{
test('metrics ledger corrupt', async () => {
const { base, gsd, mDir } = makeBase();
writeRoadmap(mDir, `# M001: Metrics Test\n\n## Slices\n- [ ] **S01: Slice** \`risk:low\` \`depends:[]\`\n > After this: done\n`);
writeSlice(mDir, "S01", "# S01: Slice\n\n**Goal:** G\n**Demo:** D\n\n## Tasks\n- [ ] **T01: Task** `est:10m`\n Pending.\n");
@ -126,16 +118,15 @@ async function main(): Promise<void> {
writeFileSync(join(gsd, "metrics.json"), '{"version":2,"data":[]}');
const result = await runGSDDoctor(base, { fix: false });
assertTrue(
assert.ok(
result.issues.some(i => i.code === "metrics_ledger_corrupt"),
"detects corrupt metrics ledger (version != 1)",
);
rmSync(base, { recursive: true, force: true });
}
});
// ── 7. Large planning file ──────────────────────────────────────────────────
console.log("\n=== large planning file ===");
{
test('large planning file', async () => {
const { base, mDir } = makeBase();
writeRoadmap(mDir, `# M001: Large File Test\n\n## Slices\n- [ ] **S01: Slice** \`risk:low\` \`depends:[]\`\n > After this: done\n`);
const sDir = writeSlice(mDir, "S01", "# S01: Slice\n\n**Goal:** G\n**Demo:** D\n\n## Tasks\n- [ ] **T01: Task** `est:10m`\n Pending.\n");
@ -144,16 +135,15 @@ async function main(): Promise<void> {
writeFileSync(join(sDir, "BIGFILE.md"), bigContent);
const result = await runGSDDoctor(base, { fix: false });
assertTrue(
assert.ok(
result.issues.some(i => i.code === "large_planning_file"),
"detects large planning file over 100KB",
);
rmSync(base, { recursive: true, force: true });
}
});
// ── 8. Future timestamp ─────────────────────────────────────────────────────
console.log("\n=== future timestamp ===");
{
test('future timestamp', async () => {
const { base, mDir } = makeBase();
writeRoadmap(mDir, `# M001: Timestamp Test\n\n## Slices\n- [ ] **S01: Slice** \`risk:low\` \`depends:[]\`\n > After this: done\n`);
const sDir = writeSlice(mDir, "S01", "# S01: Slice\n\n**Goal:** G\n**Demo:** D\n\n## Tasks\n- [x] **T01: Task** `est:10m`\n Done.\n");
@ -165,16 +155,15 @@ async function main(): Promise<void> {
);
const result = await runGSDDoctor(base, { fix: false });
assertTrue(
assert.ok(
result.issues.some(i => i.code === "future_timestamp"),
"detects future completed_at timestamp",
);
rmSync(base, { recursive: true, force: true });
}
});
// ── 9. JSON output format ───────────────────────────────────────────────────
console.log("\n=== JSON output format ===");
{
test('JSON output format', async () => {
const { base, mDir } = makeBase();
writeRoadmap(mDir, `# M001: JSON Test\n\n## Slices\n- [ ] **S01: Slice** \`risk:low\` \`depends:[]\`\n > After this: done\n`);
writeSlice(mDir, "S01", "# S01: Slice\n\n**Goal:** G\n**Demo:** D\n\n## Tasks\n- [ ] **T01: Task** `est:10m`\n Pending.\n");
@ -189,19 +178,18 @@ async function main(): Promise<void> {
parsed = null;
}
assertTrue(parsed !== null, "formatDoctorReportJson produces valid JSON");
assertTrue(typeof (parsed as Record<string, unknown>)?.ok === "boolean", "JSON has ok field");
assertTrue(Array.isArray((parsed as Record<string, unknown>)?.issues), "JSON has issues array");
assertTrue(Array.isArray((parsed as Record<string, unknown>)?.fixesApplied), "JSON has fixesApplied array");
assertTrue(typeof (parsed as Record<string, unknown>)?.generatedAt === "string", "JSON has generatedAt field");
assertTrue(typeof (parsed as Record<string, unknown>)?.summary === "object", "JSON has summary object");
assert.ok(parsed !== null, "formatDoctorReportJson produces valid JSON");
assert.ok(typeof (parsed as Record<string, unknown>)?.ok === "boolean", "JSON has ok field");
assert.ok(Array.isArray((parsed as Record<string, unknown>)?.issues), "JSON has issues array");
assert.ok(Array.isArray((parsed as Record<string, unknown>)?.fixesApplied), "JSON has fixesApplied array");
assert.ok(typeof (parsed as Record<string, unknown>)?.generatedAt === "string", "JSON has generatedAt field");
assert.ok(typeof (parsed as Record<string, unknown>)?.summary === "object", "JSON has summary object");
rmSync(base, { recursive: true, force: true });
}
});
// ── 10. Dry-run mode ────────────────────────────────────────────────────────
console.log("\n=== dry-run mode ===");
{
test('dry-run mode', async () => {
const { base, mDir } = makeBase();
writeRoadmap(mDir, `# M001: Dry Run Test\n\n## Slices\n- [ ] **S01: Slice** \`risk:low\` \`depends:[]\`\n > After this: done\n`);
writeSlice(mDir, "S01", "# S01: Slice\n\n**Goal:** G\n**Demo:** D\n\n## Tasks\n- [ ] **T01: Task** `est:10m`\n Pending.\n");
@ -209,32 +197,30 @@ async function main(): Promise<void> {
const result = await runGSDDoctor(base, { fix: true, dryRun: true });
// dry-run with fix:true still runs the doctor; shouldFix() returns false
// so no reconciliation fixes are applied through that path
assertTrue(result.issues !== undefined, "dry-run still produces issue list");
assertTrue(Array.isArray(result.fixesApplied), "dry-run report has fixesApplied array");
assert.ok(result.issues !== undefined, "dry-run still produces issue list");
assert.ok(Array.isArray(result.fixesApplied), "dry-run report has fixesApplied array");
rmSync(base, { recursive: true, force: true });
}
});
// ── 11. Per-check timing ─────────────────────────────────────────────────────
console.log("\n=== per-check timing ===");
{
test('per-check timing', async () => {
const { base, mDir } = makeBase();
writeRoadmap(mDir, `# M001: Timing Test\n\n## Slices\n- [ ] **S01: Slice** \`risk:low\` \`depends:[]\`\n > After this: done\n`);
writeSlice(mDir, "S01", "# S01: Slice\n\n**Goal:** G\n**Demo:** D\n\n## Tasks\n- [ ] **T01: Task** `est:10m`\n Pending.\n");
const result = await runGSDDoctor(base, { fix: false });
assertTrue(result.timing !== undefined, "report includes timing");
assertTrue(typeof result.timing?.git === "number", "timing.git is a number");
assertTrue(typeof result.timing?.runtime === "number", "timing.runtime is a number");
assertTrue(typeof result.timing?.environment === "number", "timing.environment is a number");
assertTrue(typeof result.timing?.gsdState === "number", "timing.gsdState is a number");
assert.ok(result.timing !== undefined, "report includes timing");
assert.ok(typeof result.timing?.git === "number", "timing.git is a number");
assert.ok(typeof result.timing?.runtime === "number", "timing.runtime is a number");
assert.ok(typeof result.timing?.environment === "number", "timing.environment is a number");
assert.ok(typeof result.timing?.gsdState === "number", "timing.gsdState is a number");
rmSync(base, { recursive: true, force: true });
}
});
// ── 12. Doctor history ───────────────────────────────────────────────────────
console.log("\n=== doctor history ===");
{
test('doctor history', async () => {
const { base, gsd, mDir } = makeBase();
writeRoadmap(mDir, `# M001: History Test\n\n## Slices\n- [ ] **S01: Slice** \`risk:low\` \`depends:[]\`\n > After this: done\n`);
writeSlice(mDir, "S01", "# S01: Slice\n\n**Goal:** G\n**Demo:** D\n\n## Tasks\n- [ ] **T01: Task** `est:10m`\n Pending.\n");
@ -242,23 +228,16 @@ async function main(): Promise<void> {
await runGSDDoctor(base, { fix: false });
const historyPath = join(gsd, "doctor-history.jsonl");
assertTrue(existsSync(historyPath), "doctor-history.jsonl is created after run");
assert.ok(existsSync(historyPath), "doctor-history.jsonl is created after run");
const { readDoctorHistory } = await import("../doctor.js");
const history = await readDoctorHistory(base);
assertTrue(history.length >= 1, "history has at least one entry");
assertTrue(typeof history[0]?.ts === "string", "history entry has ts field");
assertTrue(typeof history[0]?.ok === "boolean", "history entry has ok field");
assertTrue(typeof history[0]?.errors === "number", "history entry has errors count");
assertTrue(Array.isArray(history[0]?.codes), "history entry has codes array");
assert.ok(history.length >= 1, "history has at least one entry");
assert.ok(typeof history[0]?.ts === "string", "history entry has ts field");
assert.ok(typeof history[0]?.ok === "boolean", "history entry has ok field");
assert.ok(typeof history[0]?.errors === "number", "history entry has errors count");
assert.ok(Array.isArray(history[0]?.codes), "history entry has codes array");
rmSync(base, { recursive: true, force: true });
}
report();
}
main().catch(err => {
console.error(err);
process.exit(1);
});
});

View file

@ -1,3 +1,5 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
/**
* doctor-environment-worktree.test.ts Worktree-aware dependency checks (#2303).
*
@ -19,10 +21,6 @@ import {
environmentResultsToDoctorIssues,
checkEnvironmentHealth,
} from "../doctor-environment.ts";
import { createTestContext } from "./test-helpers.ts";
const { assertEq, assertTrue, report } = createTestContext();
/** Create a directory tree with files. */
function createDir(files: Record<string, string> = {}): string {
const dir = mkdtempSync(join(tmpdir(), "gsd-wt-env-"));
@ -34,13 +32,12 @@ function createDir(files: Record<string, string> = {}): string {
return dir;
}
async function main(): Promise<void> {
describe('doctor-environment-worktree', async () => {
const cleanups: string[] = [];
try {
// ── Reproduction: worktree path without node_modules ───────────────
console.log("\n=== worktree: missing node_modules should NOT error when project root has them ===");
{
test('worktree: missing node_modules should NOT error when project root has them', () => {
// Simulate project root with node_modules
const projectRoot = createDir({
"package.json": JSON.stringify({ name: "test-project" }),
@ -62,15 +59,14 @@ async function main(): Promise<void> {
// Before fix: this would return status "error" with "node_modules missing"
// After fix: should return "ok" because project root has node_modules
assertTrue(
assert.ok(
depsCheck === undefined || depsCheck.status !== "error",
"worktree should not report env_dependencies error when project root has node_modules",
);
}
});
// ── Worktree with NO node_modules anywhere should still error ──────
console.log("\n=== worktree: missing node_modules everywhere should still error ===");
{
test('worktree: missing node_modules everywhere should still error', () => {
const projectRoot = createDir({
"package.json": JSON.stringify({ name: "test-project" }),
});
@ -86,13 +82,12 @@ async function main(): Promise<void> {
const results = runEnvironmentChecks(worktreeDir);
const depsCheck = results.find(r => r.name === "dependencies");
assertTrue(depsCheck !== undefined, "dependencies check still runs in worktree");
assertEq(depsCheck!.status, "error", "reports error when node_modules missing everywhere");
}
assert.ok(depsCheck !== undefined, "dependencies check still runs in worktree");
assert.deepStrictEqual(depsCheck!.status, "error", "reports error when node_modules missing everywhere");
});
// ── Worktree env_dependencies not in doctor issues ──────────────────
console.log("\n=== worktree: checkEnvironmentHealth should not add env_dependencies for valid worktree ===");
{
test('worktree: checkEnvironmentHealth should not add env_dependencies for valid worktree', async () => {
const projectRoot = createDir({
"package.json": JSON.stringify({ name: "test-project" }),
});
@ -109,29 +104,27 @@ async function main(): Promise<void> {
const issues: any[] = [];
await checkEnvironmentHealth(worktreeDir, issues);
const depIssue = issues.find(i => i.code === "env_dependencies");
assertEq(
assert.deepStrictEqual(
depIssue,
undefined,
"no env_dependencies issue for worktree with project root node_modules",
);
}
});
// ── Non-worktree path still catches missing node_modules ───────────
console.log("\n=== non-worktree: missing node_modules still detected ===");
{
test('non-worktree: missing node_modules still detected', () => {
const dir = createDir({
"package.json": JSON.stringify({ name: "test" }),
});
cleanups.push(dir);
const results = runEnvironmentChecks(dir);
const depsCheck = results.find(r => r.name === "dependencies");
assertTrue(depsCheck !== undefined, "dependencies check runs");
assertEq(depsCheck!.status, "error", "missing node_modules is an error for non-worktree");
}
assert.ok(depsCheck !== undefined, "dependencies check runs");
assert.deepStrictEqual(depsCheck!.status, "error", "missing node_modules is an error for non-worktree");
});
// ── GSD_WORKTREE env var detection ─────────────────────────────────
console.log("\n=== GSD_WORKTREE env: should resolve project root node_modules ===");
{
test('GSD_WORKTREE env: should resolve project root node_modules', () => {
const projectRoot = createDir({
"package.json": JSON.stringify({ name: "test-project" }),
});
@ -150,7 +143,7 @@ async function main(): Promise<void> {
process.env.GSD_WORKTREE = projectRoot;
const results = runEnvironmentChecks(someDir);
const depsCheck = results.find(r => r.name === "dependencies");
assertTrue(
assert.ok(
depsCheck === undefined || depsCheck.status !== "error",
"GSD_WORKTREE env allows fallback to project root node_modules",
);
@ -161,15 +154,11 @@ async function main(): Promise<void> {
process.env.GSD_WORKTREE = origEnv;
}
}
}
});
} finally {
for (const dir of cleanups) {
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
}
}
report();
}
main();
});

View file

@ -1,3 +1,5 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
/**
* doctor-environment.test.ts Tests for environment health checks (#1221).
*
@ -25,10 +27,6 @@ import {
checkEnvironmentHealth,
type EnvironmentCheckResult,
} from "../doctor-environment.ts";
import { createTestContext } from "./test-helpers.ts";
const { assertEq, assertTrue, assertMatch, report } = createTestContext();
function createProjectDir(files: Record<string, string> = {}): string {
const dir = mkdtempSync(join(tmpdir(), "gsd-env-test-"));
for (const [name, content] of Object.entries(files)) {
@ -39,34 +37,31 @@ function createProjectDir(files: Record<string, string> = {}): string {
return dir;
}
async function main(): Promise<void> {
describe('doctor-environment', async () => {
const cleanups: string[] = [];
try {
// ── Node Version Check ─────────────────────────────────────────────
console.log("\n=== env: no package.json returns empty ===");
{
test('env: no package.json returns empty', () => {
const dir = createProjectDir();
cleanups.push(dir);
const results = runEnvironmentChecks(dir);
// No package.json → no node checks
const nodeCheck = results.find(r => r.name === "node_version");
assertEq(nodeCheck, undefined, "no node version check without package.json");
}
assert.deepStrictEqual(nodeCheck, undefined, "no node version check without package.json");
});
console.log("\n=== env: package.json without engines returns no node check ===");
{
test('env: package.json without engines returns no node check', () => {
const dir = createProjectDir({
"package.json": JSON.stringify({ name: "test", version: "1.0.0" }),
});
cleanups.push(dir);
const results = runEnvironmentChecks(dir);
const nodeCheck = results.find(r => r.name === "node_version");
assertEq(nodeCheck, undefined, "no node version check without engines field");
}
assert.deepStrictEqual(nodeCheck, undefined, "no node version check without engines field");
});
console.log("\n=== env: package.json with engines returns node check ===");
{
test('env: package.json with engines returns node check', () => {
const dir = createProjectDir({
"package.json": JSON.stringify({
name: "test",
@ -77,27 +72,25 @@ async function main(): Promise<void> {
cleanups.push(dir);
const results = runEnvironmentChecks(dir);
const nodeCheck = results.find(r => r.name === "node_version");
assertTrue(nodeCheck !== undefined, "node version check runs with engines field");
assert.ok(nodeCheck !== undefined, "node version check runs with engines field");
// Current node should be >= 18 in CI
assertEq(nodeCheck!.status, "ok", "node version meets requirement");
}
assert.deepStrictEqual(nodeCheck!.status, "ok", "node version meets requirement");
});
// ── Dependencies Check ─────────────────────────────────────────────
console.log("\n=== env: missing node_modules detected ===");
{
test('env: missing node_modules detected', () => {
const dir = createProjectDir({
"package.json": JSON.stringify({ name: "test" }),
});
cleanups.push(dir);
const results = runEnvironmentChecks(dir);
const depsCheck = results.find(r => r.name === "dependencies");
assertTrue(depsCheck !== undefined, "dependencies check runs");
assertEq(depsCheck!.status, "error", "missing node_modules is an error");
assertTrue(depsCheck!.message.includes("node_modules missing"), "reports missing node_modules");
}
assert.ok(depsCheck !== undefined, "dependencies check runs");
assert.deepStrictEqual(depsCheck!.status, "error", "missing node_modules is an error");
assert.ok(depsCheck!.message.includes("node_modules missing"), "reports missing node_modules");
});
console.log("\n=== env: existing node_modules detected ===");
{
test('env: existing node_modules detected', () => {
const dir = createProjectDir({
"package.json": JSON.stringify({ name: "test" }),
});
@ -105,25 +98,23 @@ async function main(): Promise<void> {
cleanups.push(dir);
const results = runEnvironmentChecks(dir);
const depsCheck = results.find(r => r.name === "dependencies");
assertTrue(depsCheck !== undefined, "dependencies check runs");
assertEq(depsCheck!.status, "ok", "existing node_modules is ok");
}
assert.ok(depsCheck !== undefined, "dependencies check runs");
assert.deepStrictEqual(depsCheck!.status, "ok", "existing node_modules is ok");
});
// ── Env File Check ─────────────────────────────────────────────────
console.log("\n=== env: .env.example without .env detected ===");
{
test('env: .env.example without .env detected', () => {
const dir = createProjectDir({
".env.example": "DB_URL=xxx\nAPI_KEY=xxx\n",
});
cleanups.push(dir);
const results = runEnvironmentChecks(dir);
const envCheck = results.find(r => r.name === "env_file");
assertTrue(envCheck !== undefined, "env file check runs");
assertEq(envCheck!.status, "warning", "missing .env is a warning");
}
assert.ok(envCheck !== undefined, "env file check runs");
assert.deepStrictEqual(envCheck!.status, "warning", "missing .env is a warning");
});
console.log("\n=== env: .env.example with .env is ok ===");
{
test('env: .env.example with .env is ok', () => {
const dir = createProjectDir({
".env.example": "DB_URL=xxx\n",
".env": "DB_URL=postgres://localhost/test\n",
@ -131,12 +122,11 @@ async function main(): Promise<void> {
cleanups.push(dir);
const results = runEnvironmentChecks(dir);
const envCheck = results.find(r => r.name === "env_file");
assertTrue(envCheck !== undefined, "env file check runs");
assertEq(envCheck!.status, "ok", "present .env is ok");
}
assert.ok(envCheck !== undefined, "env file check runs");
assert.deepStrictEqual(envCheck!.status, "ok", "present .env is ok");
});
console.log("\n=== env: .env.example with .env.local is ok ===");
{
test('env: .env.example with .env.local is ok', () => {
const dir = createProjectDir({
".env.example": "DB_URL=xxx\n",
".env.local": "DB_URL=postgres://localhost/test\n",
@ -144,25 +134,23 @@ async function main(): Promise<void> {
cleanups.push(dir);
const results = runEnvironmentChecks(dir);
const envCheck = results.find(r => r.name === "env_file");
assertTrue(envCheck !== undefined, "env file check runs");
assertEq(envCheck!.status, "ok", ".env.local counts as present");
}
assert.ok(envCheck !== undefined, "env file check runs");
assert.deepStrictEqual(envCheck!.status, "ok", ".env.local counts as present");
});
// ── Disk Space Check ───────────────────────────────────────────────
console.log("\n=== env: disk space check returns result ===");
if (process.platform !== "win32") {
const dir = createProjectDir();
cleanups.push(dir);
const results = runEnvironmentChecks(dir);
const diskCheck = results.find(r => r.name === "disk_space");
assertTrue(diskCheck !== undefined, "disk space check runs on unix");
assert.ok(diskCheck !== undefined, "disk space check runs on unix");
// Should be ok on dev machines with reasonable disk
assertTrue(diskCheck!.status === "ok" || diskCheck!.status === "warning", "disk check returns valid status");
assert.ok(diskCheck!.status === "ok" || diskCheck!.status === "warning", "disk check returns valid status");
}
// ── Project Tools Check ────────────────────────────────────────────
console.log("\n=== env: detects missing python when pyproject.toml exists ===");
{
test('env: detects missing python when pyproject.toml exists', () => {
const dir = createProjectDir({
"package.json": JSON.stringify({ name: "test" }),
"pyproject.toml": "[build-system]\nrequires = ['setuptools']\n",
@ -173,11 +161,10 @@ async function main(): Promise<void> {
const pythonCheck = results.find(r => r.name === "python");
// Python is likely installed on CI/dev machines, so just verify the check runs
// without error — the result depends on the system
assertTrue(true, "python check runs without error");
}
assert.ok(true, "python check runs without error");
});
console.log("\n=== env: detects Cargo.toml ===");
{
test('env: detects Cargo.toml', () => {
const dir = createProjectDir({
"package.json": JSON.stringify({ name: "test" }),
"Cargo.toml": "[package]\nname = 'test'\n",
@ -186,12 +173,11 @@ async function main(): Promise<void> {
cleanups.push(dir);
const results = runEnvironmentChecks(dir);
// Just verify it runs without error
assertTrue(true, "cargo check runs without error");
}
assert.ok(true, "cargo check runs without error");
});
// ── Docker Check ───────────────────────────────────────────────────
console.log("\n=== env: no docker check without Dockerfile ===");
{
test('env: no docker check without Dockerfile', () => {
const dir = createProjectDir({
"package.json": JSON.stringify({ name: "test" }),
});
@ -199,11 +185,10 @@ async function main(): Promise<void> {
cleanups.push(dir);
const results = runEnvironmentChecks(dir);
const dockerCheck = results.find(r => r.name === "docker");
assertEq(dockerCheck, undefined, "no docker check without Dockerfile");
}
assert.deepStrictEqual(dockerCheck, undefined, "no docker check without Dockerfile");
});
console.log("\n=== env: docker check with Dockerfile ===");
{
test('env: docker check with Dockerfile', () => {
const dir = createProjectDir({
"package.json": JSON.stringify({ name: "test" }),
"Dockerfile": "FROM node:22\n",
@ -213,12 +198,11 @@ async function main(): Promise<void> {
const results = runEnvironmentChecks(dir);
const dockerCheck = results.find(r => r.name === "docker");
// Docker may or may not be installed on the test machine
assertTrue(dockerCheck !== undefined, "docker check runs when Dockerfile present");
}
assert.ok(dockerCheck !== undefined, "docker check runs when Dockerfile present");
});
// ── Doctor Issue Conversion ────────────────────────────────────────
console.log("\n=== env: converts results to doctor issues ===");
{
test('env: converts results to doctor issues', () => {
const results: EnvironmentCheckResult[] = [
{ name: "node_version", status: "ok", message: "Node.js v22.0.0" },
{ name: "dependencies", status: "error", message: "node_modules missing" },
@ -226,16 +210,15 @@ async function main(): Promise<void> {
];
const issues = environmentResultsToDoctorIssues(results);
assertEq(issues.length, 2, "only non-ok results converted");
assertEq(issues[0]!.severity, "error", "error severity preserved");
assertEq(issues[0]!.code, "env_dependencies", "code prefixed with env_");
assertEq(issues[1]!.severity, "warning", "warning severity preserved");
assertTrue(issues[1]!.message.includes("Copy .env.example"), "detail included in message");
}
assert.deepStrictEqual(issues.length, 2, "only non-ok results converted");
assert.deepStrictEqual(issues[0]!.severity, "error", "error severity preserved");
assert.deepStrictEqual(issues[0]!.code, "env_dependencies", "code prefixed with env_");
assert.deepStrictEqual(issues[1]!.severity, "warning", "warning severity preserved");
assert.ok(issues[1]!.message.includes("Copy .env.example"), "detail included in message");
});
// ── checkEnvironmentHealth integration ──────────────────────────────
console.log("\n=== env: checkEnvironmentHealth adds issues to array ===");
{
test('env: checkEnvironmentHealth adds issues to array', async () => {
const dir = createProjectDir({
"package.json": JSON.stringify({ name: "test" }),
});
@ -244,12 +227,11 @@ async function main(): Promise<void> {
const issues: any[] = [];
await checkEnvironmentHealth(dir, issues);
// Should have at least the missing node_modules issue
assertTrue(issues.some(i => i.code === "env_dependencies"), "environment issues added to array");
}
assert.ok(issues.some(i => i.code === "env_dependencies"), "environment issues added to array");
});
// ── Report Formatting ──────────────────────────────────────────────
console.log("\n=== env: formatEnvironmentReport ===");
{
test('env: formatEnvironmentReport', () => {
const results: EnvironmentCheckResult[] = [
{ name: "node_version", status: "ok", message: "Node.js v22.0.0" },
{ name: "dependencies", status: "error", message: "node_modules missing", detail: "Run npm install" },
@ -257,32 +239,29 @@ async function main(): Promise<void> {
];
const report = formatEnvironmentReport(results);
assertTrue(report.includes("Environment Health:"), "has header");
assertTrue(report.includes("Node.js v22.0.0"), "includes ok result");
assertTrue(report.includes("node_modules missing"), "includes error result");
assertTrue(report.includes("Run npm install"), "includes detail for errors");
}
assert.ok(report.includes("Environment Health:"), "has header");
assert.ok(report.includes("Node.js v22.0.0"), "includes ok result");
assert.ok(report.includes("node_modules missing"), "includes error result");
assert.ok(report.includes("Run npm install"), "includes detail for errors");
});
console.log("\n=== env: formatEnvironmentReport empty ===");
{
test('env: formatEnvironmentReport empty', () => {
const report = formatEnvironmentReport([]);
assertEq(report, "No environment checks applicable.", "empty report message");
}
assert.deepStrictEqual(report, "No environment checks applicable.", "empty report message");
});
// ── Full environment checks include git remote ─────────────────────
console.log("\n=== env: runFullEnvironmentChecks includes git remote ===");
{
test('env: runFullEnvironmentChecks includes git remote', () => {
// runFullEnvironmentChecks adds git remote check
// We can't easily test this without a real git repo, but verify it doesn't throw
const dir = createProjectDir();
cleanups.push(dir);
const results = runFullEnvironmentChecks(dir);
// No git repo → no remote check, but should not throw
assertTrue(true, "runFullEnvironmentChecks does not throw on non-git dir");
}
assert.ok(true, "runFullEnvironmentChecks does not throw on non-git dir");
});
// ── Port Detection from package.json ───────────────────────────────
console.log("\n=== env: port detection from scripts ===");
if (process.platform !== "win32") {
const dir = createProjectDir({
"package.json": JSON.stringify({
@ -299,7 +278,7 @@ async function main(): Promise<void> {
// Port 3456 is unlikely to be in use, so no conflicts expected
const portConflicts = results.filter(r => r.name === "port_conflict");
// Just verify it ran without error
assertTrue(true, "port check with script-detected ports runs without error");
assert.ok(true, "port check with script-detected ports runs without error");
}
} finally {
@ -307,8 +286,4 @@ async function main(): Promise<void> {
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
}
}
report();
}
main();
});

View file

@ -1,3 +1,5 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
/**
* doctor-git.test.ts Integration tests for doctor git health checks.
*
@ -14,10 +16,6 @@ import { tmpdir } from "node:os";
import { execSync } from "node:child_process";
import { runGSDDoctor } from "../doctor.ts";
import { createTestContext } from "./test-helpers.ts";
const { assertEq, assertTrue, report } = createTestContext();
function run(cmd: string, cwd: string): string {
return execSync(cmd, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
}
@ -114,7 +112,7 @@ _None_
return dir;
}
async function main(): Promise<void> {
describe('doctor-git', async () => {
const cleanups: string[] = [];
try {
@ -124,8 +122,7 @@ async function main(): Promise<void> {
// logic is correct (tested on macOS/Linux) — the test infra doesn't
// produce matching paths on Windows CI.
if (process.platform !== "win32") {
console.log("\n=== orphaned_auto_worktree ===");
{
test('orphaned_auto_worktree', async () => {
const dir = createRepoWithCompletedMilestone();
cleanups.push(dir);
@ -135,26 +132,24 @@ async function main(): Promise<void> {
const detect = await runGSDDoctor(dir, { isolationMode: "worktree" });
const orphanIssues = detect.issues.filter(i => i.code === "orphaned_auto_worktree");
assertTrue(orphanIssues.length > 0, "detects orphaned worktree");
assertEq(orphanIssues[0]?.unitId, "M001", "orphaned worktree unitId is M001");
assert.ok(orphanIssues.length > 0, "detects orphaned worktree");
assert.deepStrictEqual(orphanIssues[0]?.unitId, "M001", "orphaned worktree unitId is M001");
const fixed = await runGSDDoctor(dir, { fix: true, isolationMode: "worktree" });
assertTrue(fixed.fixesApplied.some(f => f.includes("removed orphaned worktree")), "fix removes orphaned worktree");
assert.ok(fixed.fixesApplied.some(f => f.includes("removed orphaned worktree")), "fix removes orphaned worktree");
// Verify worktree is gone
const wtList = run("git worktree list", dir);
assertTrue(!wtList.includes("milestone/M001"), "worktree no longer listed after fix");
}
assert.ok(!wtList.includes("milestone/M001"), "worktree no longer listed after fix");
});
} else {
console.log("\n=== orphaned_auto_worktree (skipped on Windows) ===");
}
// ─── Test 2: Stale milestone branch detection & fix ────────────────
// Skip on Windows: git branch glob matching and path resolution
// behave differently in Windows temp dirs.
if (process.platform !== "win32") {
console.log("\n=== stale_milestone_branch ===");
{
test('stale_milestone_branch', async () => {
const dir = createRepoWithCompletedMilestone();
cleanups.push(dir);
@ -163,23 +158,21 @@ async function main(): Promise<void> {
const detect = await runGSDDoctor(dir, { isolationMode: "worktree" });
const staleIssues = detect.issues.filter(i => i.code === "stale_milestone_branch");
assertTrue(staleIssues.length > 0, "detects stale milestone branch");
assertEq(staleIssues[0]?.unitId, "M001", "stale branch unitId is M001");
assert.ok(staleIssues.length > 0, "detects stale milestone branch");
assert.deepStrictEqual(staleIssues[0]?.unitId, "M001", "stale branch unitId is M001");
const fixed = await runGSDDoctor(dir, { fix: true, isolationMode: "worktree" });
assertTrue(fixed.fixesApplied.some(f => f.includes("deleted stale branch")), "fix deletes stale branch");
assert.ok(fixed.fixesApplied.some(f => f.includes("deleted stale branch")), "fix deletes stale branch");
// Verify branch is gone
const branches = run("git branch --list milestone/*", dir);
assertTrue(!branches.includes("milestone/M001"), "branch gone after fix");
}
assert.ok(!branches.includes("milestone/M001"), "branch gone after fix");
});
} else {
console.log("\n=== stale_milestone_branch (skipped on Windows) ===");
}
// ─── Test 3: Corrupt merge state detection & fix ───────────────────
console.log("\n=== corrupt_merge_state ===");
{
test('corrupt_merge_state', async () => {
const dir = createRepoWithCompletedMilestone();
cleanups.push(dir);
@ -189,18 +182,17 @@ async function main(): Promise<void> {
const detect = await runGSDDoctor(dir);
const mergeIssues = detect.issues.filter(i => i.code === "corrupt_merge_state");
assertTrue(mergeIssues.length > 0, "detects corrupt merge state");
assert.ok(mergeIssues.length > 0, "detects corrupt merge state");
const fixed = await runGSDDoctor(dir, { fix: true });
assertTrue(fixed.fixesApplied.some(f => f.includes("cleaned merge state")), "fix cleans merge state");
assert.ok(fixed.fixesApplied.some(f => f.includes("cleaned merge state")), "fix cleans merge state");
// Verify MERGE_HEAD is gone
assertTrue(!existsSync(join(dir, ".git", "MERGE_HEAD")), "MERGE_HEAD removed after fix");
}
assert.ok(!existsSync(join(dir, ".git", "MERGE_HEAD")), "MERGE_HEAD removed after fix");
});
// ─── Test 4: Tracked runtime files detection & fix ─────────────────
console.log("\n=== tracked_runtime_files ===");
{
test('tracked_runtime_files', async () => {
const dir = createRepoWithCompletedMilestone();
cleanups.push(dir);
@ -213,19 +205,18 @@ async function main(): Promise<void> {
const detect = await runGSDDoctor(dir);
const trackedIssues = detect.issues.filter(i => i.code === "tracked_runtime_files");
assertTrue(trackedIssues.length > 0, "detects tracked runtime files");
assert.ok(trackedIssues.length > 0, "detects tracked runtime files");
const fixed = await runGSDDoctor(dir, { fix: true });
assertTrue(fixed.fixesApplied.some(f => f.includes("untracked")), "fix untracks runtime files");
assert.ok(fixed.fixesApplied.some(f => f.includes("untracked")), "fix untracks runtime files");
// Verify file is no longer tracked
const tracked = run("git ls-files .gsd/activity/", dir);
assertEq(tracked, "", "runtime file untracked after fix");
}
assert.deepStrictEqual(tracked, "", "runtime file untracked after fix");
});
// ─── Test 5: Non-git directory — graceful degradation ──────────────
console.log("\n=== non-git directory ===");
{
test('non-git directory', async () => {
const dir = realpathSync(mkdtempSync(join(tmpdir(), "doc-git-test-")));
cleanups.push(dir);
@ -236,15 +227,14 @@ async function main(): Promise<void> {
const gitIssues = result.issues.filter(i =>
["orphaned_auto_worktree", "stale_milestone_branch", "corrupt_merge_state", "tracked_runtime_files"].includes(i.code)
);
assertEq(gitIssues.length, 0, "no git issues in non-git directory");
assert.deepStrictEqual(gitIssues.length, 0, "no git issues in non-git directory");
// Should not throw — reaching here means no crash
assertTrue(true, "non-git directory does not crash");
}
assert.ok(true, "non-git directory does not crash");
});
// ─── Test 6: Active worktree NOT flagged (false positive prevention) ─
if (process.platform !== "win32") {
console.log("\n=== active worktree safety ===");
{
test('active worktree safety', async () => {
const dir = createRepoWithActiveMilestone();
cleanups.push(dir);
@ -254,10 +244,9 @@ async function main(): Promise<void> {
const detect = await runGSDDoctor(dir, { isolationMode: "worktree" });
const orphanIssues = detect.issues.filter(i => i.code === "orphaned_auto_worktree");
assertEq(orphanIssues.length, 0, "active worktree NOT flagged as orphaned");
}
assert.deepStrictEqual(orphanIssues.length, 0, "active worktree NOT flagged as orphaned");
});
} else {
console.log("\n=== active worktree safety (skipped on Windows) ===");
}
// ─── Test 7: none-mode skips orphaned worktree check ───────────────
@ -265,8 +254,7 @@ async function main(): Promise<void> {
// at module load time from process.cwd(). We write the prefs file to
// the test runner's cwd .gsd/preferences.md and clean up afterwards.
if (process.platform !== "win32") {
console.log("\n=== none-mode skips orphaned worktree ===");
{
test('none-mode skips orphaned worktree', async () => {
const dir = createRepoWithCompletedMilestone();
cleanups.push(dir);
@ -276,16 +264,14 @@ async function main(): Promise<void> {
const result = await runGSDDoctor(dir, { isolationMode: "none" });
const orphanIssues = result.issues.filter(i => i.code === "orphaned_auto_worktree");
assertEq(orphanIssues.length, 0, "none-mode: orphaned worktree NOT detected");
}
assert.deepStrictEqual(orphanIssues.length, 0, "none-mode: orphaned worktree NOT detected");
});
} else {
console.log("\n=== none-mode skips orphaned worktree (skipped on Windows) ===");
}
// ─── Test 8: none-mode skips stale branch check ────────────────────
if (process.platform !== "win32") {
console.log("\n=== none-mode skips stale branch ===");
{
test('none-mode skips stale branch', async () => {
const dir = createRepoWithCompletedMilestone();
cleanups.push(dir);
@ -294,16 +280,14 @@ async function main(): Promise<void> {
const result = await runGSDDoctor(dir, { isolationMode: "none" });
const staleIssues = result.issues.filter(i => i.code === "stale_milestone_branch");
assertEq(staleIssues.length, 0, "none-mode: stale branch NOT detected");
}
assert.deepStrictEqual(staleIssues.length, 0, "none-mode: stale branch NOT detected");
});
} else {
console.log("\n=== none-mode skips stale branch (skipped on Windows) ===");
}
// ─── Test: Integration branch missing ──────────────────────────────
if (process.platform !== "win32") {
console.log("\n=== integration_branch_missing ===");
{
test('integration_branch_missing', async () => {
const dir = createRepoWithActiveMilestone();
cleanups.push(dir);
@ -313,22 +297,20 @@ async function main(): Promise<void> {
const detect = await runGSDDoctor(dir);
const missingBranchIssues = detect.issues.filter(i => i.code === "integration_branch_missing");
assertTrue(missingBranchIssues.length > 0, "detects missing integration branch");
assertTrue(
assert.ok(missingBranchIssues.length > 0, "detects missing integration branch");
assert.ok(
missingBranchIssues[0]?.message.includes("feat/does-not-exist"),
"message includes the missing branch name",
);
assertEq(missingBranchIssues[0]?.fixable, true, "integration_branch_missing is auto-fixable via fallback");
assertEq(missingBranchIssues[0]?.severity, "warning", "severity is warning (fallback available)");
}
assert.deepStrictEqual(missingBranchIssues[0]?.fixable, true, "integration_branch_missing is auto-fixable via fallback");
assert.deepStrictEqual(missingBranchIssues[0]?.severity, "warning", "severity is warning (fallback available)");
});
} else {
console.log("\n=== integration_branch_missing (skipped on Windows) ===");
}
// ─── Test: Integration branch present — no false positive ──────────
if (process.platform !== "win32") {
console.log("\n=== integration_branch_missing (no false positive) ===");
{
test('integration_branch_missing (no false positive)', async () => {
const dir = createRepoWithActiveMilestone();
cleanups.push(dir);
@ -338,15 +320,13 @@ async function main(): Promise<void> {
const detect = await runGSDDoctor(dir);
const missingBranchIssues = detect.issues.filter(i => i.code === "integration_branch_missing");
assertEq(missingBranchIssues.length, 0, "existing integration branch NOT flagged");
}
assert.deepStrictEqual(missingBranchIssues.length, 0, "existing integration branch NOT flagged");
});
} else {
console.log("\n=== integration_branch_missing (no false positive — skipped on Windows) ===");
}
// ─── Test: Orphaned worktree directory ─────────────────────────────
console.log("\n=== integration_branch_missing: stale metadata with detected fallback ===");
{
test('integration_branch_missing: stale metadata with detected fallback', async () => {
const dir = createRepoWithActiveMilestone();
cleanups.push(dir);
@ -355,27 +335,26 @@ async function main(): Promise<void> {
const detect = await runGSDDoctor(dir);
const missingBranchIssues = detect.issues.filter(i => i.code === "integration_branch_missing");
assertEq(missingBranchIssues.length, 1, "reports one stale integration branch issue");
assertEq(missingBranchIssues[0]?.severity, "warning", "stale metadata is warning when a fallback branch exists");
assertEq(missingBranchIssues[0]?.fixable, true, "stale metadata becomes auto-fixable when fallback exists");
assertTrue(
assert.deepStrictEqual(missingBranchIssues.length, 1, "reports one stale integration branch issue");
assert.deepStrictEqual(missingBranchIssues[0]?.severity, "warning", "stale metadata is warning when a fallback branch exists");
assert.deepStrictEqual(missingBranchIssues[0]?.fixable, true, "stale metadata becomes auto-fixable when fallback exists");
assert.ok(
missingBranchIssues[0]?.message.includes("feat/does-not-exist") &&
missingBranchIssues[0]?.message.includes("main"),
"warning mentions stale recorded branch and detected fallback branch",
);
const fixed = await runGSDDoctor(dir, { fix: true });
assertTrue(
assert.ok(
fixed.fixesApplied.some(f => f.includes('updated integration branch for M001 to "main"')),
"doctor fix rewrites stale integration branch metadata to detected fallback branch",
);
const repairedMeta = JSON.parse(readFileSync(metaPath, "utf-8"));
assertEq(repairedMeta.integrationBranch, "main", "metadata rewritten to detected fallback branch");
}
assert.deepStrictEqual(repairedMeta.integrationBranch, "main", "metadata rewritten to detected fallback branch");
});
console.log("\n=== integration_branch_missing: stale metadata with configured fallback ===");
{
test('integration_branch_missing: stale metadata with configured fallback', async () => {
const dir = createRepoWithActiveMilestone();
cleanups.push(dir);
@ -390,17 +369,17 @@ async function main(): Promise<void> {
try {
const detect = await runGSDDoctor(dir);
const missingBranchIssues = detect.issues.filter(i => i.code === "integration_branch_missing");
assertEq(missingBranchIssues.length, 1, "configured fallback still reports one stale integration branch issue");
assertEq(missingBranchIssues[0]?.severity, "warning", "configured fallback keeps stale metadata at warning severity");
assertEq(missingBranchIssues[0]?.fixable, true, "configured fallback remains auto-fixable");
assertTrue(
assert.deepStrictEqual(missingBranchIssues.length, 1, "configured fallback still reports one stale integration branch issue");
assert.deepStrictEqual(missingBranchIssues[0]?.severity, "warning", "configured fallback keeps stale metadata at warning severity");
assert.deepStrictEqual(missingBranchIssues[0]?.fixable, true, "configured fallback remains auto-fixable");
assert.ok(
missingBranchIssues[0]?.message.includes("feat/does-not-exist") &&
missingBranchIssues[0]?.message.includes("trunk"),
"warning mentions stale recorded branch and configured fallback branch",
);
const fixed = await runGSDDoctor(dir, { fix: true });
assertTrue(
assert.ok(
fixed.fixesApplied.some(f => f.includes('updated integration branch for M001 to "trunk"')),
"doctor fix rewrites stale metadata to configured fallback branch",
);
@ -409,12 +388,11 @@ async function main(): Promise<void> {
}
const repairedMeta = JSON.parse(readFileSync(metaPath, "utf-8"));
assertEq(repairedMeta.integrationBranch, "trunk", "metadata rewritten to configured fallback branch");
}
assert.deepStrictEqual(repairedMeta.integrationBranch, "trunk", "metadata rewritten to configured fallback branch");
});
if (process.platform !== "win32") {
console.log("\n=== worktree_directory_orphaned ===");
{
test('worktree_directory_orphaned', async () => {
const dir = createRepoWithActiveMilestone();
cleanups.push(dir);
@ -425,28 +403,26 @@ async function main(): Promise<void> {
const detect = await runGSDDoctor(dir);
const orphanDirIssues = detect.issues.filter(i => i.code === "worktree_directory_orphaned");
assertTrue(orphanDirIssues.length > 0, "detects orphaned worktree directory");
assertTrue(
assert.ok(orphanDirIssues.length > 0, "detects orphaned worktree directory");
assert.ok(
orphanDirIssues[0]?.message.includes("orphan-feature"),
"message includes the orphaned directory name",
);
assertTrue(orphanDirIssues[0]?.fixable === true, "worktree_directory_orphaned is fixable");
assert.ok(orphanDirIssues[0]?.fixable === true, "worktree_directory_orphaned is fixable");
const fixed = await runGSDDoctor(dir, { fix: true });
assertTrue(
assert.ok(
fixed.fixesApplied.some(f => f.includes("removed orphaned worktree directory")),
"fix removes orphaned worktree directory",
);
assertTrue(!existsSync(orphanDir), "orphaned directory removed after fix");
}
assert.ok(!existsSync(orphanDir), "orphaned directory removed after fix");
});
} else {
console.log("\n=== worktree_directory_orphaned (skipped on Windows) ===");
}
// ─── Test: Registered worktree NOT flagged as orphaned ─────────────
if (process.platform !== "win32") {
console.log("\n=== worktree_directory_orphaned (registered worktree not flagged) ===");
{
test('worktree_directory_orphaned (registered worktree not flagged)', async () => {
const dir = createRepoWithActiveMilestone();
cleanups.push(dir);
@ -456,15 +432,13 @@ async function main(): Promise<void> {
const detect = await runGSDDoctor(dir);
const orphanDirIssues = detect.issues.filter(i => i.code === "worktree_directory_orphaned");
assertEq(orphanDirIssues.length, 0, "registered worktree NOT flagged as orphaned");
}
assert.deepStrictEqual(orphanDirIssues.length, 0, "registered worktree NOT flagged as orphaned");
});
} else {
console.log("\n=== worktree_directory_orphaned (registered worktree not flagged — skipped on Windows) ===");
}
// ─── Test 9: none-mode still detects corrupt merge state ───────────
console.log("\n=== none-mode keeps corrupt merge state ===");
{
test('none-mode keeps corrupt merge state', async () => {
const dir = createRepoWithCompletedMilestone();
cleanups.push(dir);
@ -474,12 +448,11 @@ async function main(): Promise<void> {
const result = await runGSDDoctor(dir, { isolationMode: "none" });
const mergeIssues = result.issues.filter(i => i.code === "corrupt_merge_state");
assertTrue(mergeIssues.length > 0, "none-mode: corrupt merge state IS detected");
}
assert.ok(mergeIssues.length > 0, "none-mode: corrupt merge state IS detected");
});
// ─── Test 10: none-mode still detects tracked runtime files ────────
console.log("\n=== none-mode keeps tracked runtime files ===");
{
test('none-mode keeps tracked runtime files', async () => {
const dir = createRepoWithCompletedMilestone();
cleanups.push(dir);
@ -492,13 +465,12 @@ async function main(): Promise<void> {
const result = await runGSDDoctor(dir, { isolationMode: "none" });
const trackedIssues = result.issues.filter(i => i.code === "tracked_runtime_files");
assertTrue(trackedIssues.length > 0, "none-mode: tracked runtime files IS detected");
}
assert.ok(trackedIssues.length > 0, "none-mode: tracked runtime files IS detected");
});
// ─── Test: Symlinked .gsd does not cause false orphan detection ────
if (process.platform !== "win32") {
console.log("\n=== worktree_directory_orphaned (symlinked .gsd not false-positive) ===");
{
test('worktree_directory_orphaned (symlinked .gsd not false-positive)', async () => {
const dir = createRepoWithActiveMilestone();
cleanups.push(dir);
@ -515,16 +487,14 @@ async function main(): Promise<void> {
const detect = await runGSDDoctor(dir);
const orphanDirIssues = detect.issues.filter(i => i.code === "worktree_directory_orphaned");
assertEq(orphanDirIssues.length, 0, "registered worktree via symlinked .gsd NOT flagged as orphaned");
}
assert.deepStrictEqual(orphanDirIssues.length, 0, "registered worktree via symlinked .gsd NOT flagged as orphaned");
});
} else {
console.log("\n=== worktree_directory_orphaned (symlinked .gsd — skipped on Windows) ===");
}
// ─── Test: worktree_branch_merged detection & fix ──────────────────
if (process.platform !== "win32") {
console.log("\n=== worktree_branch_merged ===");
{
test('worktree_branch_merged', async () => {
const dir = createRepoWithActiveMilestone();
cleanups.push(dir);
@ -541,23 +511,21 @@ async function main(): Promise<void> {
const detect = await runGSDDoctor(dir);
const mergedIssues = detect.issues.filter(i => i.code === "worktree_branch_merged");
assertTrue(mergedIssues.length > 0, "detects merged worktree branch");
assertTrue(mergedIssues[0]?.message.includes("safe to remove"), "message says safe to remove");
assertTrue(mergedIssues[0]?.fixable === true, "merged worktree is fixable");
assert.ok(mergedIssues.length > 0, "detects merged worktree branch");
assert.ok(mergedIssues[0]?.message.includes("safe to remove"), "message says safe to remove");
assert.ok(mergedIssues[0]?.fixable === true, "merged worktree is fixable");
// Fix should remove the worktree
const fixed = await runGSDDoctor(dir, { fix: true });
assertTrue(fixed.fixesApplied.some(f => f.includes("removed merged worktree")), "fix removes merged worktree");
assertTrue(!existsSync(wtPath), "worktree directory removed after fix");
}
assert.ok(fixed.fixesApplied.some(f => f.includes("removed merged worktree")), "fix removes merged worktree");
assert.ok(!existsSync(wtPath), "worktree directory removed after fix");
});
} else {
console.log("\n=== worktree_branch_merged (skipped on Windows) ===");
}
// ─── Test: merged milestone/* worktree removes milestone branch ────
if (process.platform !== "win32") {
console.log("\n=== worktree_branch_merged (milestone branch cleanup) ===");
{
test('worktree_branch_merged (milestone branch cleanup)', async () => {
const dir = createRepoWithActiveMilestone();
cleanups.push(dir);
@ -570,20 +538,18 @@ async function main(): Promise<void> {
run("git merge milestone/M001 --no-edit", dir);
const fixed = await runGSDDoctor(dir, { fix: true });
assertTrue(fixed.fixesApplied.some(f => f.includes("removed merged worktree")), "fix removes merged milestone worktree");
assertTrue(!existsSync(wtPath), "milestone worktree directory removed after fix");
assert.ok(fixed.fixesApplied.some(f => f.includes("removed merged worktree")), "fix removes merged milestone worktree");
assert.ok(!existsSync(wtPath), "milestone worktree directory removed after fix");
const branches = run("git branch --list milestone/M001", dir);
assertEq(branches, "", "milestone/M001 branch deleted after merged worktree cleanup");
}
assert.deepStrictEqual(branches, "", "milestone/M001 branch deleted after merged worktree cleanup");
});
} else {
console.log("\n=== worktree_branch_merged (milestone branch cleanup — skipped on Windows) ===");
}
// ─── Test: worktree_branch_merged NOT flagged for unmerged worktree ─
if (process.platform !== "win32") {
console.log("\n=== worktree_branch_merged (no false positive) ===");
{
test('worktree_branch_merged (no false positive)', async () => {
const dir = createRepoWithActiveMilestone();
cleanups.push(dir);
@ -597,16 +563,14 @@ async function main(): Promise<void> {
// Do NOT merge — branch is ahead of main
const detect = await runGSDDoctor(dir);
const mergedIssues = detect.issues.filter(i => i.code === "worktree_branch_merged");
assertEq(mergedIssues.length, 0, "unmerged worktree NOT flagged as merged");
}
assert.deepStrictEqual(mergedIssues.length, 0, "unmerged worktree NOT flagged as merged");
});
} else {
console.log("\n=== worktree_branch_merged (no false positive — skipped on Windows) ===");
}
// ─── Test: legacy_slice_branches now fixable ───────────────────────
if (process.platform !== "win32") {
console.log("\n=== legacy_slice_branches (fixable) ===");
{
test('legacy_slice_branches (fixable)', async () => {
const dir = createRepoWithActiveMilestone();
cleanups.push(dir);
@ -618,18 +582,17 @@ async function main(): Promise<void> {
const detect = await runGSDDoctor(dir);
const legacyIssues = detect.issues.filter(i => i.code === "legacy_slice_branches");
assertTrue(legacyIssues.length > 0, "detects legacy slice branches");
assertTrue(legacyIssues[0]?.fixable === true, "legacy branches are fixable");
assert.ok(legacyIssues.length > 0, "detects legacy slice branches");
assert.ok(legacyIssues[0]?.fixable === true, "legacy branches are fixable");
const fixed = await runGSDDoctor(dir, { fix: true });
assertTrue(fixed.fixesApplied.some(f => f.includes("legacy slice branch")), "fix deletes legacy branches");
assert.ok(fixed.fixesApplied.some(f => f.includes("legacy slice branch")), "fix deletes legacy branches");
// Verify branches are gone
const remaining = run("git branch --list gsd/*/*", dir);
assertEq(remaining, "gsd/quick/1-fix-typo", "quick branch preserved; legacy branches removed");
}
assert.deepStrictEqual(remaining, "gsd/quick/1-fix-typo", "quick branch preserved; legacy branches removed");
});
} else {
console.log("\n=== legacy_slice_branches (fixable — skipped on Windows) ===");
}
} finally {
@ -637,8 +600,4 @@ async function main(): Promise<void> {
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
}
}
report();
}
main();
});

View file

@ -1,3 +1,5 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
/**
* doctor-proactive.test.ts Tests for proactive healing layer.
*
@ -22,10 +24,6 @@ import {
resetProactiveHealing,
formatHealthSummary,
} from "../doctor-proactive.ts";
import { createTestContext } from "./test-helpers.ts";
const { assertEq, assertTrue, report } = createTestContext();
function run(cmd: string, cwd: string): string {
return execSync(cmd, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
}
@ -70,44 +68,40 @@ _None_
return dir;
}
async function main(): Promise<void> {
describe('doctor-proactive', async () => {
const cleanups: string[] = [];
try {
// ─── Health Score Tracking ─────────────────────────────────────────
console.log("\n=== health tracking: initial state ===");
{
test('health tracking: initial state', () => {
resetProactiveHealing();
assertEq(getHealthTrend(), "unknown", "trend is unknown with no data");
assertEq(getConsecutiveErrorUnits(), 0, "no consecutive errors initially");
assertEq(getHealthHistory().length, 0, "no history initially");
}
assert.deepStrictEqual(getHealthTrend(), "unknown", "trend is unknown with no data");
assert.deepStrictEqual(getConsecutiveErrorUnits(), 0, "no consecutive errors initially");
assert.deepStrictEqual(getHealthHistory().length, 0, "no history initially");
});
console.log("\n=== health tracking: recording snapshots ===");
{
test('health tracking: recording snapshots', () => {
resetProactiveHealing();
recordHealthSnapshot(0, 2, 1);
recordHealthSnapshot(0, 1, 0);
recordHealthSnapshot(0, 0, 0);
assertEq(getHealthHistory().length, 3, "3 snapshots recorded");
assertEq(getConsecutiveErrorUnits(), 0, "no consecutive errors after clean units");
}
assert.deepStrictEqual(getHealthHistory().length, 3, "3 snapshots recorded");
assert.deepStrictEqual(getConsecutiveErrorUnits(), 0, "no consecutive errors after clean units");
});
console.log("\n=== health tracking: consecutive error counting ===");
{
test('health tracking: consecutive error counting', () => {
resetProactiveHealing();
recordHealthSnapshot(2, 1, 0); // errors
recordHealthSnapshot(1, 0, 0); // errors
recordHealthSnapshot(1, 0, 0); // errors
assertEq(getConsecutiveErrorUnits(), 3, "3 consecutive error units");
assert.deepStrictEqual(getConsecutiveErrorUnits(), 3, "3 consecutive error units");
recordHealthSnapshot(0, 0, 0); // clean
assertEq(getConsecutiveErrorUnits(), 0, "streak reset on clean unit");
}
assert.deepStrictEqual(getConsecutiveErrorUnits(), 0, "streak reset on clean unit");
});
console.log("\n=== health tracking: trend detection ===");
{
test('health tracking: trend detection', () => {
resetProactiveHealing();
// Record 5 older snapshots with low issues
for (let i = 0; i < 5; i++) {
@ -117,11 +111,10 @@ async function main(): Promise<void> {
for (let i = 0; i < 5; i++) {
recordHealthSnapshot(3, 5, 0);
}
assertEq(getHealthTrend(), "degrading", "detects degrading trend");
}
assert.deepStrictEqual(getHealthTrend(), "degrading", "detects degrading trend");
});
console.log("\n=== health tracking: improving trend ===");
{
test('health tracking: improving trend', () => {
resetProactiveHealing();
// Record 5 older snapshots with high issues
for (let i = 0; i < 5; i++) {
@ -131,32 +124,29 @@ async function main(): Promise<void> {
for (let i = 0; i < 5; i++) {
recordHealthSnapshot(0, 0, 0);
}
assertEq(getHealthTrend(), "improving", "detects improving trend");
}
assert.deepStrictEqual(getHealthTrend(), "improving", "detects improving trend");
});
console.log("\n=== health tracking: stable trend ===");
{
test('health tracking: stable trend', () => {
resetProactiveHealing();
for (let i = 0; i < 10; i++) {
recordHealthSnapshot(1, 1, 0);
}
assertEq(getHealthTrend(), "stable", "detects stable trend");
}
assert.deepStrictEqual(getHealthTrend(), "stable", "detects stable trend");
});
// ─── Auto-Heal Escalation ─────────────────────────────────────────
console.log("\n=== escalation: below threshold ===");
{
test('escalation: below threshold', () => {
resetProactiveHealing();
recordHealthSnapshot(1, 0, 0);
recordHealthSnapshot(1, 0, 0);
recordHealthSnapshot(1, 0, 0);
const result = checkHealEscalation(1, [{ code: "test", message: "test error", unitId: "M001/S01" }]);
assertEq(result.shouldEscalate, false, "no escalation below threshold");
assertTrue(result.reason.includes("3/5"), "reason shows progress toward threshold");
}
assert.deepStrictEqual(result.shouldEscalate, false, "no escalation below threshold");
assert.ok(result.reason.includes("3/5"), "reason shows progress toward threshold");
});
console.log("\n=== escalation: at threshold ===");
{
test('escalation: at threshold', () => {
resetProactiveHealing();
// Need 5+ consecutive error units AND degrading/stable trend
for (let i = 0; i < 5; i++) {
@ -166,21 +156,19 @@ async function main(): Promise<void> {
recordHealthSnapshot(2, 1, 0); // recent error snapshots
}
const result = checkHealEscalation(2, [{ code: "test", message: "test error", unitId: "M001/S01" }]);
assertEq(result.shouldEscalate, true, "escalates at threshold with degrading trend");
assertTrue(result.reason.includes("5 consecutive"), "reason mentions consecutive count");
}
assert.deepStrictEqual(result.shouldEscalate, true, "escalates at threshold with degrading trend");
assert.ok(result.reason.includes("5 consecutive"), "reason mentions consecutive count");
});
console.log("\n=== escalation: no double escalation ===");
{
test('escalation: no double escalation', () => {
// Don't reset — should already be escalated from previous test
recordHealthSnapshot(2, 0, 0);
const result = checkHealEscalation(2, [{ code: "test", message: "test error", unitId: "M001/S01" }]);
assertEq(result.shouldEscalate, false, "no double escalation in same session");
assertTrue(result.reason.includes("already escalated"), "reason explains why no escalation");
}
assert.deepStrictEqual(result.shouldEscalate, false, "no double escalation in same session");
assert.ok(result.reason.includes("already escalated"), "reason explains why no escalation");
});
console.log("\n=== escalation: deferred when improving ===");
{
test('escalation: deferred when improving', () => {
resetProactiveHealing();
// 5 older snapshots with high errors
for (let i = 0; i < 5; i++) {
@ -191,37 +179,34 @@ async function main(): Promise<void> {
recordHealthSnapshot(1, 0, 0);
}
const result = checkHealEscalation(1, [{ code: "test", message: "test error", unitId: "M001/S01" }]);
assertEq(result.shouldEscalate, false, "no escalation when trend is improving");
assertTrue(result.reason.includes("improving"), "reason mentions improving trend");
}
assert.deepStrictEqual(result.shouldEscalate, false, "no escalation when trend is improving");
assert.ok(result.reason.includes("improving"), "reason mentions improving trend");
});
// ─── Health Summary Formatting ────────────────────────────────────
console.log("\n=== formatHealthSummary ===");
{
test('formatHealthSummary', () => {
resetProactiveHealing();
assertEq(formatHealthSummary(), "No health data yet.", "empty summary when no data");
assert.deepStrictEqual(formatHealthSummary(), "No health data yet.", "empty summary when no data");
recordHealthSnapshot(2, 3, 1);
const summary = formatHealthSummary();
assertTrue(summary.includes("2 errors") && summary.includes("3 warnings"), "summary includes error/warning counts");
assertTrue(summary.includes("1 fix applied"), "summary includes fix count");
assertTrue(summary.includes("1 of 5 consecutive errors"), "summary includes error streak");
}
assert.ok(summary.includes("2 errors") && summary.includes("3 warnings"), "summary includes error/warning counts");
assert.ok(summary.includes("1 fix applied"), "summary includes fix count");
assert.ok(summary.includes("1 of 5 consecutive errors"), "summary includes error streak");
});
// ─── Pre-Dispatch Health Gate ─────────────────────────────────────
console.log("\n=== health gate: clean state ===");
{
test('health gate: clean state', async () => {
const dir = realpathSync(mkdtempSync(join(tmpdir(), "doc-proactive-")));
cleanups.push(dir);
mkdirSync(join(dir, ".gsd"), { recursive: true });
const result = await preDispatchHealthGate(dir);
assertTrue(result.proceed, "gate passes on clean state");
assertEq(result.issues.length, 0, "no issues on clean state");
}
assert.ok(result.proceed, "gate passes on clean state");
assert.deepStrictEqual(result.issues.length, 0, "no issues on clean state");
});
console.log("\n=== health gate: missing STATE.md does NOT block dispatch (#889) ===");
{
test('health gate: missing STATE.md does NOT block dispatch (#889)', async () => {
const dir = realpathSync(mkdtempSync(join(tmpdir(), "doc-proactive-")));
cleanups.push(dir);
// Create milestones dir but no STATE.md — mimics fresh worktree
@ -229,13 +214,12 @@ async function main(): Promise<void> {
writeFileSync(join(dir, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "# Roadmap\n");
const result = await preDispatchHealthGate(dir);
assertTrue(result.proceed, "gate must NOT block when STATE.md is missing (deadlock #889)");
assertEq(result.issues.length, 0, "missing STATE.md is not a blocking issue");
assertTrue(result.fixesApplied.some((f: string) => f.includes("STATE.md")), "reports STATE.md status as info");
}
assert.ok(result.proceed, "gate must NOT block when STATE.md is missing (deadlock #889)");
assert.deepStrictEqual(result.issues.length, 0, "missing STATE.md is not a blocking issue");
assert.ok(result.fixesApplied.some((f: string) => f.includes("STATE.md")), "reports STATE.md status as info");
});
console.log("\n=== health gate: stale crash lock auto-cleared ===");
{
test('health gate: stale crash lock auto-cleared', async () => {
const dir = realpathSync(mkdtempSync(join(tmpdir(), "doc-proactive-")));
cleanups.push(dir);
mkdirSync(join(dir, ".gsd"), { recursive: true });
@ -248,12 +232,12 @@ async function main(): Promise<void> {
}));
const result = await preDispatchHealthGate(dir);
assertTrue(result.proceed, "gate passes after auto-clearing stale lock");
assertTrue(result.fixesApplied.some(f => f.includes("cleared stale auto.lock")), "reports lock cleared");
assertTrue(!existsSync(join(dir, ".gsd", "auto.lock")), "lock file removed");
}
assert.ok(result.proceed, "gate passes after auto-clearing stale lock");
assert.ok(result.fixesApplied.some(f => f.includes("cleared stale auto.lock")), "reports lock cleared");
assert.ok(!existsSync(join(dir, ".gsd", "auto.lock")), "lock file removed");
});
console.log("\n=== health gate: corrupt merge state auto-healed ===");
test('health gate: corrupt merge state auto-healed', async () => {
if (process.platform !== "win32") {
{
const dir = createGitRepo();
@ -264,36 +248,35 @@ async function main(): Promise<void> {
writeFileSync(join(dir, ".git", "MERGE_HEAD"), headHash + "\n");
const result = await preDispatchHealthGate(dir);
assertTrue(result.proceed, "gate passes after auto-healing merge state");
assertTrue(result.fixesApplied.some(f => f.includes("cleaned merge state")), "reports merge state cleaned");
assertTrue(!existsSync(join(dir, ".git", "MERGE_HEAD")), "MERGE_HEAD removed");
assert.ok(result.proceed, "gate passes after auto-healing merge state");
assert.ok(result.fixesApplied.some(f => f.includes("cleaned merge state")), "reports merge state cleaned");
assert.ok(!existsSync(join(dir, ".git", "MERGE_HEAD")), "MERGE_HEAD removed");
}
} else {
console.log(" (skipped on Windows)");
}
});
console.log("\n=== health gate: STATE.md missing — auto-healed ===");
{
test('health gate: STATE.md missing — auto-healed', async () => {
const dir = realpathSync(mkdtempSync(join(tmpdir(), "doc-proactive-")));
cleanups.push(dir);
// Minimal .gsd structure: milestones dir exists but no STATE.md
mkdirSync(join(dir, ".gsd", "milestones"), { recursive: true });
const stateFile = join(dir, ".gsd", "STATE.md");
assertTrue(!existsSync(stateFile), "STATE.md does not exist before gate");
assert.ok(!existsSync(stateFile), "STATE.md does not exist before gate");
const result = await preDispatchHealthGate(dir);
assertTrue(result.proceed, "gate passes after rebuilding STATE.md");
assertTrue(
assert.ok(result.proceed, "gate passes after rebuilding STATE.md");
assert.ok(
result.fixesApplied.some(f => f.includes("rebuilt missing STATE.md")),
"reports STATE.md rebuilt",
);
assertTrue(existsSync(stateFile), "STATE.md created by auto-heal");
assertTrue(result.issues.length === 0, "no blocking issues after heal");
}
assert.ok(existsSync(stateFile), "STATE.md created by auto-heal");
assert.ok(result.issues.length === 0, "no blocking issues after heal");
});
console.log("\n=== health gate: stale integration branch uses detected fallback ===");
{
test('health gate: stale integration branch uses detected fallback', async () => {
const dir = createRepoWithActiveMilestone();
cleanups.push(dir);
@ -301,16 +284,15 @@ async function main(): Promise<void> {
writeFileSync(metaPath, JSON.stringify({ integrationBranch: "feature/missing" }, null, 2));
const result = await preDispatchHealthGate(dir);
assertTrue(result.proceed, "gate does not block when stale integration branch has detected fallback");
assertEq(result.issues.length, 0, "stale integration branch with fallback is not a blocking issue");
assertTrue(
assert.ok(result.proceed, "gate does not block when stale integration branch has detected fallback");
assert.deepStrictEqual(result.issues.length, 0, "stale integration branch with fallback is not a blocking issue");
assert.ok(
result.fixesApplied.some(f => f.includes('feature/missing') && f.includes('main')),
"fixesApplied reports stale recorded branch and detected fallback branch",
);
}
});
console.log("\n=== health gate: stale integration branch uses configured fallback ===");
{
test('health gate: stale integration branch uses configured fallback', async () => {
const dir = createRepoWithActiveMilestone();
cleanups.push(dir);
@ -323,16 +305,16 @@ async function main(): Promise<void> {
process.chdir(dir);
try {
const result = await preDispatchHealthGate(dir);
assertTrue(result.proceed, "gate does not block when configured main_branch can be used as fallback");
assertEq(result.issues.length, 0, "configured fallback is not treated as a blocking issue");
assertTrue(
assert.ok(result.proceed, "gate does not block when configured main_branch can be used as fallback");
assert.deepStrictEqual(result.issues.length, 0, "configured fallback is not treated as a blocking issue");
assert.ok(
result.fixesApplied.some(f => f.includes('feature/missing') && f.includes('trunk')),
"fixesApplied reports stale recorded branch and configured fallback branch",
);
} finally {
process.chdir(previousCwd);
}
}
});
} finally {
resetProactiveHealing();
@ -340,8 +322,4 @@ async function main(): Promise<void> {
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
}
}
report();
}
main();
});

View file

@ -1,3 +1,5 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
/**
* doctor-runtime.test.ts Tests for doctor runtime health checks.
*
@ -13,10 +15,6 @@ import { tmpdir } from "node:os";
import { execSync } from "node:child_process";
import { runGSDDoctor } from "../doctor.ts";
import { createTestContext } from "./test-helpers.ts";
const { assertEq, assertTrue, report } = createTestContext();
function run(cmd: string, cwd: string): string {
return execSync(cmd, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
}
@ -57,13 +55,12 @@ function createGitProject(): string {
return dir;
}
async function main(): Promise<void> {
describe('doctor-runtime', async () => {
const cleanups: string[] = [];
try {
// ─── Test 1: Stale crash lock detection & fix ─────────────────────
console.log("\n=== stale_crash_lock ===");
{
test('stale_crash_lock', async () => {
const dir = createMinimalProject();
cleanups.push(dir);
@ -80,29 +77,27 @@ async function main(): Promise<void> {
const detect = await runGSDDoctor(dir);
const lockIssues = detect.issues.filter(i => i.code === "stale_crash_lock");
assertTrue(lockIssues.length > 0, "detects stale crash lock");
assertTrue(lockIssues[0]?.message.includes("9999999"), "message includes PID");
assertTrue(lockIssues[0]?.fixable === true, "stale lock is fixable");
assert.ok(lockIssues.length > 0, "detects stale crash lock");
assert.ok(lockIssues[0]?.message.includes("9999999"), "message includes PID");
assert.ok(lockIssues[0]?.fixable === true, "stale lock is fixable");
const fixed = await runGSDDoctor(dir, { fix: true });
assertTrue(fixed.fixesApplied.some(f => f.includes("cleared stale auto.lock")), "fix clears stale lock");
assertTrue(!existsSync(join(dir, ".gsd", "auto.lock")), "auto.lock removed after fix");
}
assert.ok(fixed.fixesApplied.some(f => f.includes("cleared stale auto.lock")), "fix clears stale lock");
assert.ok(!existsSync(join(dir, ".gsd", "auto.lock")), "auto.lock removed after fix");
});
// ─── Test 2: No false positive for missing lock ───────────────────
console.log("\n=== stale_crash_lock — no false positive ===");
{
test('stale_crash_lock — no false positive', async () => {
const dir = createMinimalProject();
cleanups.push(dir);
const detect = await runGSDDoctor(dir);
const lockIssues = detect.issues.filter(i => i.code === "stale_crash_lock");
assertEq(lockIssues.length, 0, "no stale lock issue when no lock file exists");
}
assert.deepStrictEqual(lockIssues.length, 0, "no stale lock issue when no lock file exists");
});
// ─── Test 3: Stale hook state detection & fix ─────────────────────
console.log("\n=== stale_hook_state ===");
{
test('stale_hook_state', async () => {
const dir = createMinimalProject();
cleanups.push(dir);
@ -118,20 +113,19 @@ async function main(): Promise<void> {
const detect = await runGSDDoctor(dir);
const hookIssues = detect.issues.filter(i => i.code === "stale_hook_state");
assertTrue(hookIssues.length > 0, "detects stale hook state");
assertTrue(hookIssues[0]?.message.includes("2 residual cycle count"), "message includes count");
assert.ok(hookIssues.length > 0, "detects stale hook state");
assert.ok(hookIssues[0]?.message.includes("2 residual cycle count"), "message includes count");
const fixed = await runGSDDoctor(dir, { fix: true });
assertTrue(fixed.fixesApplied.some(f => f.includes("cleared stale hook-state.json")), "fix clears hook state");
assert.ok(fixed.fixesApplied.some(f => f.includes("cleared stale hook-state.json")), "fix clears hook state");
// Verify the file was cleaned
const content = JSON.parse(readFileSync(join(dir, ".gsd", "hook-state.json"), "utf-8"));
assertEq(Object.keys(content.cycleCounts).length, 0, "hook state cycle counts cleared");
}
assert.deepStrictEqual(Object.keys(content.cycleCounts).length, 0, "hook state cycle counts cleared");
});
// ─── Test 4: Activity log bloat detection ─────────────────────────
console.log("\n=== activity_log_bloat ===");
{
test('activity_log_bloat', async () => {
const dir = createMinimalProject();
cleanups.push(dir);
@ -144,39 +138,37 @@ async function main(): Promise<void> {
const detect = await runGSDDoctor(dir);
const bloatIssues = detect.issues.filter(i => i.code === "activity_log_bloat");
assertTrue(bloatIssues.length > 0, "detects activity log bloat");
assertTrue(bloatIssues[0]?.message.includes("510 files"), "message includes file count");
}
assert.ok(bloatIssues.length > 0, "detects activity log bloat");
assert.ok(bloatIssues[0]?.message.includes("510 files"), "message includes file count");
});
// ─── Test 5: STATE.md missing detection & fix ─────────────────────
console.log("\n=== state_file_missing ===");
{
test('state_file_missing', async () => {
const dir = createMinimalProject();
cleanups.push(dir);
// No STATE.md exists by default in our minimal setup
const stateFilePath = join(dir, ".gsd", "STATE.md");
assertTrue(!existsSync(stateFilePath), "STATE.md does not exist initially");
assert.ok(!existsSync(stateFilePath), "STATE.md does not exist initially");
const detect = await runGSDDoctor(dir);
const stateIssues = detect.issues.filter(i => i.code === "state_file_missing");
assertTrue(stateIssues.length > 0, "detects missing STATE.md");
assertTrue(stateIssues[0]?.fixable === true, "missing STATE.md is fixable");
assertEq(stateIssues[0]?.severity, "warning", "missing STATE.md is a warning (derived file)");
assert.ok(stateIssues.length > 0, "detects missing STATE.md");
assert.ok(stateIssues[0]?.fixable === true, "missing STATE.md is fixable");
assert.deepStrictEqual(stateIssues[0]?.severity, "warning", "missing STATE.md is a warning (derived file)");
const fixed = await runGSDDoctor(dir, { fix: true });
assertTrue(fixed.fixesApplied.some(f => f.includes("created STATE.md")), "fix creates STATE.md");
assertTrue(existsSync(stateFilePath), "STATE.md exists after fix");
assert.ok(fixed.fixesApplied.some(f => f.includes("created STATE.md")), "fix creates STATE.md");
assert.ok(existsSync(stateFilePath), "STATE.md exists after fix");
// Verify content has expected structure
const content = readFileSync(stateFilePath, "utf-8");
assertTrue(content.includes("# GSD State"), "STATE.md has header");
assertTrue(content.includes("M001"), "STATE.md references milestone");
}
assert.ok(content.includes("# GSD State"), "STATE.md has header");
assert.ok(content.includes("M001"), "STATE.md references milestone");
});
// ─── Test 6: STATE.md stale detection & fix ───────────────────────
console.log("\n=== state_file_stale ===");
{
test('state_file_stale', async () => {
const dir = createMinimalProject();
cleanups.push(dir);
@ -202,21 +194,20 @@ None
const detect = await runGSDDoctor(dir);
const staleIssues = detect.issues.filter(i => i.code === "state_file_stale");
assertTrue(staleIssues.length > 0, "detects stale STATE.md");
assertTrue(staleIssues[0]?.message.includes("idle"), "message references old phase");
assert.ok(staleIssues.length > 0, "detects stale STATE.md");
assert.ok(staleIssues[0]?.message.includes("idle"), "message references old phase");
const fixed = await runGSDDoctor(dir, { fix: true });
assertTrue(fixed.fixesApplied.some(f => f.includes("rebuilt STATE.md")), "fix rebuilds STATE.md");
assert.ok(fixed.fixesApplied.some(f => f.includes("rebuilt STATE.md")), "fix rebuilds STATE.md");
// Verify updated content matches derived state
const content = readFileSync(stateFilePath, "utf-8");
assertTrue(content.includes("M001"), "rebuilt STATE.md references milestone");
}
assert.ok(content.includes("M001"), "rebuilt STATE.md references milestone");
});
// ─── Test 7: Gitignore missing patterns detection & fix ───────────
if (process.platform !== "win32") {
console.log("\n=== gitignore_missing_patterns ===");
{
test('gitignore_missing_patterns', async () => {
const dir = createGitProject();
cleanups.push(dir);
@ -230,24 +221,22 @@ None
const detect = await runGSDDoctor(dir);
const gitignoreIssues = detect.issues.filter(i => i.code === "gitignore_missing_patterns");
assertTrue(gitignoreIssues.length > 0, "detects missing gitignore patterns");
assertTrue(gitignoreIssues[0]?.message.includes(".gsd"), "message lists missing .gsd pattern");
assert.ok(gitignoreIssues.length > 0, "detects missing gitignore patterns");
assert.ok(gitignoreIssues[0]?.message.includes(".gsd"), "message lists missing .gsd pattern");
const fixed = await runGSDDoctor(dir, { fix: true });
assertTrue(fixed.fixesApplied.some(f => f.includes("added missing GSD runtime patterns")), "fix adds patterns");
assert.ok(fixed.fixesApplied.some(f => f.includes("added missing GSD runtime patterns")), "fix adds patterns");
// Verify .gsd entry was added (external state symlink)
const content = readFileSync(join(dir, ".gitignore"), "utf-8");
assertTrue(content.includes(".gsd"), "gitignore now has .gsd entry");
}
assert.ok(content.includes(".gsd"), "gitignore now has .gsd entry");
});
} else {
console.log("\n=== gitignore_missing_patterns (skipped on Windows) ===");
}
// ─── Test 8: No false positive when gitignore has blanket .gsd/ ───
if (process.platform !== "win32") {
console.log("\n=== gitignore — blanket .gsd/ ===");
{
test('gitignore — blanket .gsd/', async () => {
const dir = createGitProject();
cleanups.push(dir);
@ -258,15 +247,13 @@ node_modules/
const detect = await runGSDDoctor(dir);
const gitignoreIssues = detect.issues.filter(i => i.code === "gitignore_missing_patterns");
assertEq(gitignoreIssues.length, 0, "no missing patterns when blanket .gsd/ present");
}
assert.deepStrictEqual(gitignoreIssues.length, 0, "no missing patterns when blanket .gsd/ present");
});
} else {
console.log("\n=== gitignore — blanket .gsd/ (skipped on Windows) ===");
}
// ─── Test 9: Orphaned completed-units detection & fix ─────────────
console.log("\n=== orphaned_completed_units ===");
{
test('orphaned_completed_units', async () => {
const dir = createMinimalProject();
cleanups.push(dir);
@ -279,24 +266,23 @@ node_modules/
const detect = await runGSDDoctor(dir);
const orphanIssues = detect.issues.filter(i => i.code === "orphaned_completed_units");
assertTrue(orphanIssues.length > 0, "detects orphaned completed-unit keys");
assertTrue(orphanIssues[0]?.message.includes("2 completed-unit key"), "message includes count");
assert.ok(orphanIssues.length > 0, "detects orphaned completed-unit keys");
assert.ok(orphanIssues[0]?.message.includes("2 completed-unit key"), "message includes count");
const fixed = await runGSDDoctor(dir, { fix: true });
assertTrue(fixed.fixesApplied.some(f => f.includes("removed") && f.includes("orphaned")), "fix removes orphaned keys");
assert.ok(fixed.fixesApplied.some(f => f.includes("removed") && f.includes("orphaned")), "fix removes orphaned keys");
// Verify keys were cleaned
const content = JSON.parse(readFileSync(join(dir, ".gsd", "completed-units.json"), "utf-8"));
assertEq(content.length, 0, "all orphaned keys removed");
}
assert.deepStrictEqual(content.length, 0, "all orphaned keys removed");
});
// ─── Test: Stranded lock directory detection & fix ────────────────
// Skip on Windows: proper-lockfile uses advisory file locking on Windows,
// not the directory-based mechanism. The .gsd.lock/ directory pattern is
// a POSIX-specific lockfile implementation detail.
if (process.platform !== "win32") {
console.log("\n=== stranded_lock_directory ===");
{
test('stranded_lock_directory', async () => {
const dir = createMinimalProject();
cleanups.push(dir);
@ -307,21 +293,20 @@ node_modules/
const detect = await runGSDDoctor(dir);
const strandedIssues = detect.issues.filter(i => i.code === "stranded_lock_directory");
assertTrue(strandedIssues.length > 0, "detects stranded lock directory");
assertTrue(strandedIssues[0]?.message.includes("lock directory"), "message describes stranded lock directory");
assertTrue(strandedIssues[0]?.fixable === true, "stranded lock dir is fixable");
assert.ok(strandedIssues.length > 0, "detects stranded lock directory");
assert.ok(strandedIssues[0]?.message.includes("lock directory"), "message describes stranded lock directory");
assert.ok(strandedIssues[0]?.fixable === true, "stranded lock dir is fixable");
const fixed = await runGSDDoctor(dir, { fix: true });
assertTrue(
assert.ok(
fixed.fixesApplied.some(f => f.includes("removed stranded lock directory")),
"fix removes stranded lock directory",
);
assertTrue(!existsSync(lockDir), "lock directory removed after fix");
}
assert.ok(!existsSync(lockDir), "lock directory removed after fix");
});
// ─── Test: Stranded lock dir with live lock holder — NOT flagged ───
console.log("\n=== stranded_lock_directory (live holder not flagged) ===");
{
test('stranded_lock_directory (live holder not flagged)', async () => {
const dir = createMinimalProject();
cleanups.push(dir);
@ -340,18 +325,16 @@ node_modules/
const detect = await runGSDDoctor(dir);
const strandedIssues = detect.issues.filter(i => i.code === "stranded_lock_directory");
assertEq(strandedIssues.length, 0, "live lock holder: stranded_lock_directory NOT detected");
}
assert.deepStrictEqual(strandedIssues.length, 0, "live lock holder: stranded_lock_directory NOT detected");
});
} else {
console.log("\n=== stranded_lock_directory (skipped on Windows) ===");
}
// ─── Test: orphaned_completed_units NOT auto-fixed at fixLevel="task" (#1809) ──
// Regression: task-level doctor was removing completed-unit keys whose artifacts
// were temporarily missing, causing deriveState to revert the user to S01 and
// effectively discarding hours of work.
console.log("\n=== orphaned_completed_units protected at fixLevel=task (#1809) ===");
{
test('orphaned_completed_units protected at fixLevel=task (#1809)', async () => {
const dir = createMinimalProject();
cleanups.push(dir);
@ -366,33 +349,29 @@ node_modules/
// fixLevel="task" — the level used by auto-post-unit after every task
const taskLevelFix = await runGSDDoctor(dir, { fix: true, fixLevel: "task" });
const taskLevelOrphan = taskLevelFix.issues.filter(i => i.code === "orphaned_completed_units");
assertTrue(taskLevelOrphan.length > 0, "orphaned_completed_units detected at task fixLevel");
assert.ok(taskLevelOrphan.length > 0, "orphaned_completed_units detected at task fixLevel");
// Verify keys were NOT removed — the fix must be suppressed at task level
const afterTaskFix = JSON.parse(readFileSync(join(dir, ".gsd", "completed-units.json"), "utf-8"));
assertEq(afterTaskFix.length, 2, "completed-unit keys preserved at fixLevel=task (data loss prevention)");
assertTrue(
assert.deepStrictEqual(afterTaskFix.length, 2, "completed-unit keys preserved at fixLevel=task (data loss prevention)");
assert.ok(
!taskLevelFix.fixesApplied.some(f => f.includes("orphaned")),
"no orphaned-units fix applied at fixLevel=task",
);
// fixLevel="all" (explicit manual doctor) — fix SHOULD apply
const allLevelFix = await runGSDDoctor(dir, { fix: true, fixLevel: "all" });
assertTrue(
assert.ok(
allLevelFix.fixesApplied.some(f => f.includes("orphaned")),
"orphaned-units fix applied at fixLevel=all (manual doctor)",
);
const afterAllFix = JSON.parse(readFileSync(join(dir, ".gsd", "completed-units.json"), "utf-8"));
assertEq(afterAllFix.length, 0, "orphaned keys removed at fixLevel=all");
}
assert.deepStrictEqual(afterAllFix.length, 0, "orphaned keys removed at fixLevel=all");
});
} finally {
for (const dir of cleanups) {
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
}
}
report();
}
main();
});

View file

@ -1,11 +1,10 @@
import { after, describe, test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { formatDoctorReport, runGSDDoctor, summarizeDoctorIssues, filterDoctorIssues, selectDoctorScope, validateTitle } from "../doctor.js";
import { createTestContext } from './test-helpers.ts';
const { assertEq, assertTrue, report } = createTestContext();
const tmpBase = mkdtempSync(join(tmpdir(), "gsd-doctor-test-"));
const gsd = join(tmpBase, ".gsd");
const mDir = join(gsd, "milestones", "M001");
@ -61,46 +60,41 @@ Implemented.
- log
`);
async function main(): Promise<void> {
console.log("\n=== doctor diagnose ===");
{
describe('doctor', async () => {
test('doctor diagnose', async () => {
const report = await runGSDDoctor(tmpBase, { fix: false });
// Reconciliation issue codes have been removed — doctor should NOT report them
assertTrue(!report.issues.some(issue => issue.code === "all_tasks_done_missing_slice_summary" as any), "does not report removed code all_tasks_done_missing_slice_summary");
assertTrue(!report.issues.some(issue => issue.code === "all_tasks_done_missing_slice_uat" as any), "does not report removed code all_tasks_done_missing_slice_uat");
assertTrue(!report.issues.some(issue => issue.code === "all_tasks_done_roadmap_not_checked" as any), "does not report removed code all_tasks_done_roadmap_not_checked");
}
assert.ok(!report.issues.some(issue => issue.code === "all_tasks_done_missing_slice_summary" as any), "does not report removed code all_tasks_done_missing_slice_summary");
assert.ok(!report.issues.some(issue => issue.code === "all_tasks_done_missing_slice_uat" as any), "does not report removed code all_tasks_done_missing_slice_uat");
assert.ok(!report.issues.some(issue => issue.code === "all_tasks_done_roadmap_not_checked" as any), "does not report removed code all_tasks_done_roadmap_not_checked");
});
console.log("\n=== doctor formatting ===");
{
test('doctor formatting', async () => {
const report = await runGSDDoctor(tmpBase, { fix: false });
const summary = summarizeDoctorIssues(report.issues);
const scoped = filterDoctorIssues(report.issues, { scope: "M001/S01", includeWarnings: true });
const text = formatDoctorReport(report, { scope: "M001/S01", includeWarnings: true, maxIssues: 5 });
assertTrue(text.includes("Scope: M001/S01"), "formatted report shows scope");
}
assert.ok(text.includes("Scope: M001/S01"), "formatted report shows scope");
});
console.log("\n=== doctor default scope ===");
{
test('doctor default scope', async () => {
const scope = await selectDoctorScope(tmpBase);
assertEq(scope, "M001/S01", "default doctor scope targets the active slice");
}
assert.deepStrictEqual(scope, "M001/S01", "default doctor scope targets the active slice");
});
console.log("\n=== doctor fix ===");
{
test('doctor fix', async () => {
const report = await runGSDDoctor(tmpBase, { fix: true });
// With reconciliation removed, doctor no longer creates placeholder summaries,
// UAT files, or marks checkboxes. It only applies infrastructure fixes.
// The task checkbox marking (task_summary_without_done_checkbox) is also removed.
// Just verify it doesn't crash and produces a report.
assertTrue(report.issues !== undefined, "doctor produces a report with issues array");
}
assert.ok(report.issues !== undefined, "doctor produces a report with issues array");
});
rmSync(tmpBase, { recursive: true, force: true });
after(() => rmSync(tmpBase, { recursive: true, force: true }));
// ─── Milestone summary detection: missing summary ──────────────────────
console.log("\n=== doctor detects missing milestone summary ===");
{
test('doctor detects missing milestone summary', async () => {
const msBase = mkdtempSync(join(tmpdir(), "gsd-doctor-ms-test-"));
const msGsd = join(msBase, ".gsd");
const msMDir = join(msGsd, "milestones", "M001");
@ -153,22 +147,21 @@ parent: M001
// NO milestone summary — this is the condition we're detecting
const report = await runGSDDoctor(msBase, { fix: false });
assertTrue(
assert.ok(
report.issues.some(issue => issue.code === "all_slices_done_missing_milestone_summary"),
"detects missing milestone summary when all slices are done"
);
const msIssue = report.issues.find(issue => issue.code === "all_slices_done_missing_milestone_summary");
assertEq(msIssue?.scope, "milestone", "milestone summary issue has scope 'milestone'");
assertEq(msIssue?.severity, "warning", "milestone summary issue has severity 'warning'");
assertEq(msIssue?.unitId, "M001", "milestone summary issue unitId is 'M001'");
assertTrue(msIssue?.message?.includes("SUMMARY") ?? false, "milestone summary issue message mentions SUMMARY");
assert.deepStrictEqual(msIssue?.scope, "milestone", "milestone summary issue has scope 'milestone'");
assert.deepStrictEqual(msIssue?.severity, "warning", "milestone summary issue has severity 'warning'");
assert.deepStrictEqual(msIssue?.unitId, "M001", "milestone summary issue unitId is 'M001'");
assert.ok(msIssue?.message?.includes("SUMMARY") ?? false, "milestone summary issue message mentions SUMMARY");
rmSync(msBase, { recursive: true, force: true });
}
});
// ─── Milestone summary detection: summary present (no false positive) ──
console.log("\n=== doctor does NOT flag milestone with summary ===");
{
test('doctor does NOT flag milestone with summary', async () => {
const msBase = mkdtempSync(join(tmpdir(), "gsd-doctor-ms-ok-test-"));
const msGsd = join(msBase, ".gsd");
const msMDir = join(msGsd, "milestones", "M001");
@ -218,17 +211,16 @@ parent: M001
writeFileSync(join(msMDir, "M001-SUMMARY.md"), `# M001 Summary\n\nMilestone complete.`);
const report = await runGSDDoctor(msBase, { fix: false });
assertTrue(
assert.ok(
!report.issues.some(issue => issue.code === "all_slices_done_missing_milestone_summary"),
"does NOT report missing milestone summary when summary exists"
);
rmSync(msBase, { recursive: true, force: true });
}
});
// ─── blocker_discovered_no_replan detection ────────────────────────────
console.log("\n=== doctor detects blocker_discovered_no_replan ===");
{
test('doctor detects blocker_discovered_no_replan', async () => {
const bBase = mkdtempSync(join(tmpdir(), "gsd-doctor-blocker-test-"));
const bGsd = join(bBase, ".gsd");
const bMDir = join(bGsd, "milestones", "M001");
@ -284,18 +276,17 @@ Discovered an issue.
// No REPLAN.md — should trigger the issue
const report = await runGSDDoctor(bBase, { fix: false });
const blockerIssues = report.issues.filter(i => i.code === "blocker_discovered_no_replan");
assertTrue(blockerIssues.length > 0, "detects blocker_discovered_no_replan");
assertEq(blockerIssues[0]?.severity, "warning", "blocker issue has warning severity");
assertEq(blockerIssues[0]?.scope, "slice", "blocker issue has slice scope");
assertTrue(blockerIssues[0]?.message?.includes("T01") ?? false, "blocker issue message mentions T01");
assertTrue(blockerIssues[0]?.message?.includes("S01") ?? false, "blocker issue message mentions S01");
assert.ok(blockerIssues.length > 0, "detects blocker_discovered_no_replan");
assert.deepStrictEqual(blockerIssues[0]?.severity, "warning", "blocker issue has warning severity");
assert.deepStrictEqual(blockerIssues[0]?.scope, "slice", "blocker issue has slice scope");
assert.ok(blockerIssues[0]?.message?.includes("T01") ?? false, "blocker issue message mentions T01");
assert.ok(blockerIssues[0]?.message?.includes("S01") ?? false, "blocker issue message mentions S01");
rmSync(bBase, { recursive: true, force: true });
}
});
// ─── blocker_discovered with REPLAN.md (no false positive) ─────────────
console.log("\n=== doctor does NOT flag blocker when REPLAN.md exists ===");
{
test('doctor does NOT flag blocker when REPLAN.md exists', async () => {
const bBase = mkdtempSync(join(tmpdir(), "gsd-doctor-blocker-ok-test-"));
const bGsd = join(bBase, ".gsd");
const bMDir = join(bGsd, "milestones", "M001");
@ -345,14 +336,13 @@ Discovered an issue.
const report = await runGSDDoctor(bBase, { fix: false });
const blockerIssues = report.issues.filter(i => i.code === "blocker_discovered_no_replan");
assertEq(blockerIssues.length, 0, "no blocker_discovered_no_replan when REPLAN.md exists");
assert.deepStrictEqual(blockerIssues.length, 0, "no blocker_discovered_no_replan when REPLAN.md exists");
rmSync(bBase, { recursive: true, force: true });
}
});
// ─── Must-have verification: all addressed → no issue ─────────────────
console.log("\n=== doctor: done task with must-haves all addressed → no issue ===");
{
test('doctor: done task with must-haves all addressed → no issue', async () => {
const mhBase = mkdtempSync(join(tmpdir(), "gsd-doctor-mh-ok-"));
const mhGsd = join(mhBase, ".gsd");
const mhMDir = join(mhGsd, "milestones", "M001");
@ -370,17 +360,16 @@ Discovered an issue.
writeFileSync(join(mhTDir, "T01-SUMMARY.md"), `---\nid: T01\nparent: S01\nmilestone: M001\n---\n# T01: Implement\n\n## What Happened\nAdded parseWidgets function. Unit tests pass with zero failures.\n`);
const report = await runGSDDoctor(mhBase, { fix: false });
assertTrue(
assert.ok(
!report.issues.some(i => i.code === "task_done_must_haves_not_verified"),
"no must-have issue when all must-haves are addressed"
);
rmSync(mhBase, { recursive: true, force: true });
}
});
// ─── Must-have verification: not addressed → warning fired ───────────
console.log("\n=== doctor: done task with must-haves NOT addressed → warning ===");
{
test('doctor: done task with must-haves NOT addressed → warning', async () => {
const mhBase = mkdtempSync(join(tmpdir(), "gsd-doctor-mh-fail-"));
const mhGsd = join(mhBase, ".gsd");
const mhMDir = join(mhGsd, "milestones", "M001");
@ -399,19 +388,18 @@ Discovered an issue.
const report = await runGSDDoctor(mhBase, { fix: false });
const mhIssue = report.issues.find(i => i.code === "task_done_must_haves_not_verified");
assertTrue(!!mhIssue, "must-have issue is fired when summary doesn't address all must-haves");
assertEq(mhIssue?.severity, "warning", "must-have issue is warning severity");
assertEq(mhIssue?.scope, "task", "must-have issue scope is task");
assertTrue(mhIssue?.message?.includes("3 must-haves") ?? false, "message mentions total must-have count");
assertTrue(mhIssue?.message?.includes("only 1") ?? false, "message mentions addressed count");
assertEq(mhIssue?.fixable, false, "must-have issue is not fixable");
assert.ok(!!mhIssue, "must-have issue is fired when summary doesn't address all must-haves");
assert.deepStrictEqual(mhIssue?.severity, "warning", "must-have issue is warning severity");
assert.deepStrictEqual(mhIssue?.scope, "task", "must-have issue scope is task");
assert.ok(mhIssue?.message?.includes("3 must-haves") ?? false, "message mentions total must-have count");
assert.ok(mhIssue?.message?.includes("only 1") ?? false, "message mentions addressed count");
assert.deepStrictEqual(mhIssue?.fixable, false, "must-have issue is not fixable");
rmSync(mhBase, { recursive: true, force: true });
}
});
// ─── Must-have verification: no task plan → no issue ─────────────────
console.log("\n=== doctor: done task with no task plan file → no issue ===");
{
test('doctor: done task with no task plan file → no issue', async () => {
const mhBase = mkdtempSync(join(tmpdir(), "gsd-doctor-mh-noplan-"));
const mhGsd = join(mhBase, ".gsd");
const mhMDir = join(mhGsd, "milestones", "M001");
@ -426,17 +414,16 @@ Discovered an issue.
writeFileSync(join(mhTDir, "T01-SUMMARY.md"), `---\nid: T01\nparent: S01\nmilestone: M001\n---\n# T01: Implement\n\n## What Happened\nDone.\n`);
const report = await runGSDDoctor(mhBase, { fix: false });
assertTrue(
assert.ok(
!report.issues.some(i => i.code === "task_done_must_haves_not_verified"),
"no must-have issue when task plan file doesn't exist"
);
rmSync(mhBase, { recursive: true, force: true });
}
});
// ─── Must-have verification: plan exists but no Must-Haves section → no issue
console.log("\n=== doctor: done task with plan but no Must-Haves section → no issue ===");
{
test('doctor: done task with plan but no Must-Haves section → no issue', async () => {
const mhBase = mkdtempSync(join(tmpdir(), "gsd-doctor-mh-nosect-"));
const mhGsd = join(mhBase, ".gsd");
const mhMDir = join(mhGsd, "milestones", "M001");
@ -453,55 +440,49 @@ Discovered an issue.
writeFileSync(join(mhTDir, "T01-SUMMARY.md"), `---\nid: T01\nparent: S01\nmilestone: M001\n---\n# T01: Implement\n\n## What Happened\nDone.\n`);
const report = await runGSDDoctor(mhBase, { fix: false });
assertTrue(
assert.ok(
!report.issues.some(i => i.code === "task_done_must_haves_not_verified"),
"no must-have issue when task plan has no Must-Haves section"
);
rmSync(mhBase, { recursive: true, force: true });
}
});
// ─── validateTitle: em dash and slash detection ────────────────────────
console.log("\n=== validateTitle: returns null for clean titles ===");
{
assertEq(validateTitle("Foundation"), null, "clean title passes");
assertEq(validateTitle("Build Core Systems"), null, "clean title with spaces passes");
assertEq(validateTitle("API v2 Integration"), null, "clean title with version passes");
assertEq(validateTitle(""), null, "empty title passes");
}
test('validateTitle: returns null for clean titles', () => {
assert.deepStrictEqual(validateTitle("Foundation"), null, "clean title passes");
assert.deepStrictEqual(validateTitle("Build Core Systems"), null, "clean title with spaces passes");
assert.deepStrictEqual(validateTitle("API v2 Integration"), null, "clean title with version passes");
assert.deepStrictEqual(validateTitle(""), null, "empty title passes");
});
console.log("\n=== validateTitle: detects em dash ===");
{
test('validateTitle: detects em dash', () => {
const result = validateTitle("Foundation — Build Core");
assertTrue(result !== null, "detects em dash in title");
assertTrue(result!.includes("em/en dash"), "message mentions em/en dash");
}
assert.ok(result !== null, "detects em dash in title");
assert.ok(result!.includes("em/en dash"), "message mentions em/en dash");
});
console.log("\n=== validateTitle: detects en dash ===");
{
test('validateTitle: detects en dash', () => {
const result = validateTitle("Phase 1 Phase 2");
assertTrue(result !== null, "detects en dash in title");
assertTrue(result!.includes("em/en dash"), "message mentions em/en dash for en dash");
}
assert.ok(result !== null, "detects en dash in title");
assert.ok(result!.includes("em/en dash"), "message mentions em/en dash for en dash");
});
console.log("\n=== validateTitle: detects forward slash ===");
{
test('validateTitle: detects forward slash', () => {
const result = validateTitle("Client/Server");
assertTrue(result !== null, "detects forward slash in title");
assertTrue(result!.includes("forward slash"), "message mentions forward slash");
}
assert.ok(result !== null, "detects forward slash in title");
assert.ok(result!.includes("forward slash"), "message mentions forward slash");
});
console.log("\n=== validateTitle: detects both em dash and slash ===");
{
test('validateTitle: detects both em dash and slash', () => {
const result = validateTitle("Client — Server/API");
assertTrue(result !== null, "detects both delimiters");
assertTrue(result!.includes("em/en dash"), "message mentions em/en dash");
assertTrue(result!.includes("forward slash"), "message mentions forward slash");
}
assert.ok(result !== null, "detects both delimiters");
assert.ok(result!.includes("em/en dash"), "message mentions em/en dash");
assert.ok(result!.includes("forward slash"), "message mentions forward slash");
});
// ─── doctor detects delimiter_in_title for milestone ───────────────────
console.log("\n=== doctor detects em dash in milestone title ===");
{
test('doctor detects em dash in milestone title', async () => {
const dtBase = mkdtempSync(join(tmpdir(), "gsd-doctor-dt-test-"));
const dtGsd = join(dtBase, ".gsd");
const dtMDir = join(dtGsd, "milestones", "M001");
@ -516,20 +497,19 @@ Discovered an issue.
const report = await runGSDDoctor(dtBase, { fix: false });
const dtIssues = report.issues.filter(i => i.code === "delimiter_in_title");
assertTrue(dtIssues.length >= 1, "detects delimiter_in_title for milestone with em dash");
assert.ok(dtIssues.length >= 1, "detects delimiter_in_title for milestone with em dash");
const milestoneIssue = dtIssues.find(i => i.scope === "milestone");
assertTrue(milestoneIssue !== undefined, "delimiter issue has milestone scope");
assertEq(milestoneIssue?.severity, "warning", "delimiter issue has warning severity");
assertEq(milestoneIssue?.unitId, "M001", "delimiter issue unitId is M001");
assertTrue(milestoneIssue?.message?.includes("em/en dash") ?? false, "issue message mentions em/en dash");
assertEq(milestoneIssue?.fixable, true, "delimiter issue is auto-fixable");
assert.ok(milestoneIssue !== undefined, "delimiter issue has milestone scope");
assert.deepStrictEqual(milestoneIssue?.severity, "warning", "delimiter issue has warning severity");
assert.deepStrictEqual(milestoneIssue?.unitId, "M001", "delimiter issue unitId is M001");
assert.ok(milestoneIssue?.message?.includes("em/en dash") ?? false, "issue message mentions em/en dash");
assert.deepStrictEqual(milestoneIssue?.fixable, true, "delimiter issue is auto-fixable");
rmSync(dtBase, { recursive: true, force: true });
}
});
// ─── doctor detects delimiter_in_title for slice ────────────────────────
console.log("\n=== doctor detects em dash in slice title ===");
{
test('doctor detects em dash in slice title', async () => {
const dtBase = mkdtempSync(join(tmpdir(), "gsd-doctor-dt-slice-"));
const dtGsd = join(dtBase, ".gsd");
const dtMDir = join(dtGsd, "milestones", "M001");
@ -544,18 +524,17 @@ Discovered an issue.
const report = await runGSDDoctor(dtBase, { fix: false });
const dtIssues = report.issues.filter(i => i.code === "delimiter_in_title");
assertTrue(dtIssues.length >= 1, "detects delimiter_in_title for slice with em dash");
assert.ok(dtIssues.length >= 1, "detects delimiter_in_title for slice with em dash");
const sliceIssue = dtIssues.find(i => i.scope === "slice");
assertTrue(sliceIssue !== undefined, "delimiter issue has slice scope");
assertEq(sliceIssue?.severity, "warning", "slice delimiter issue has warning severity");
assertEq(sliceIssue?.unitId, "M001/S01", "slice delimiter issue unitId is M001/S01");
assert.ok(sliceIssue !== undefined, "delimiter issue has slice scope");
assert.deepStrictEqual(sliceIssue?.severity, "warning", "slice delimiter issue has warning severity");
assert.deepStrictEqual(sliceIssue?.unitId, "M001/S01", "slice delimiter issue unitId is M001/S01");
rmSync(dtBase, { recursive: true, force: true });
}
});
// ─── doctor does NOT flag clean titles ──────────────────────────────────
console.log("\n=== doctor does NOT flag milestone with clean title ===");
{
test('doctor does NOT flag milestone with clean title', async () => {
const dtBase = mkdtempSync(join(tmpdir(), "gsd-doctor-dt-clean-"));
const dtGsd = join(dtBase, ".gsd");
const dtMDir = join(dtGsd, "milestones", "M001");
@ -570,14 +549,13 @@ Discovered an issue.
const report = await runGSDDoctor(dtBase, { fix: false });
const dtIssues = report.issues.filter(i => i.code === "delimiter_in_title");
assertEq(dtIssues.length, 0, "no delimiter_in_title issues for clean titles");
assert.deepStrictEqual(dtIssues.length, 0, "no delimiter_in_title issues for clean titles");
rmSync(dtBase, { recursive: true, force: true });
}
});
// ─── unresolvable_dependency: range syntax dep warns ─────────────────
console.log("\n=== doctor: unresolvable_dependency warns for leftover range ID ===");
{
test('doctor: unresolvable_dependency warns for leftover range ID', async () => {
// Simulate a roadmap where expandDependencies did NOT expand (pre-fix stored artifact)
// by writing a dep that looks like a range but doesn't match any real slice.
const base = mkdtempSync(join(tmpdir(), "gsd-doctor-udep-"));
@ -599,16 +577,15 @@ Discovered an issue.
const r = await runGSDDoctor(base, { fix: false });
const udepIssues = r.issues.filter(i => i.code === "unresolvable_dependency");
assertTrue(udepIssues.length > 0, "unresolvable_dependency fires for unknown dep S99");
assertEq(udepIssues[0]?.severity, "warning", "severity is warning");
assertTrue(udepIssues[0]?.message.includes("S99"), "message names the bad dep");
assert.ok(udepIssues.length > 0, "unresolvable_dependency fires for unknown dep S99");
assert.deepStrictEqual(udepIssues[0]?.severity, "warning", "severity is warning");
assert.ok(udepIssues[0]?.message.includes("S99"), "message names the bad dep");
rmSync(base, { recursive: true, force: true });
}
});
// ─── unresolvable_dependency: valid deps do not warn ─────────────────
console.log("\n=== doctor: no unresolvable_dependency for valid deps ===");
{
test('doctor: no unresolvable_dependency for valid deps', async () => {
const base = mkdtempSync(join(tmpdir(), "gsd-doctor-udep-ok-"));
const mDir2 = join(base, ".gsd", "milestones", "M001");
const sDir2 = join(mDir2, "slices", "S01");
@ -628,15 +605,8 @@ Discovered an issue.
const r = await runGSDDoctor(base, { fix: false });
const udepIssues = r.issues.filter(i => i.code === "unresolvable_dependency");
assertEq(udepIssues.length, 0, "no unresolvable_dependency for valid S01 dep");
assert.deepStrictEqual(udepIssues.length, 0, "no unresolvable_dependency for valid S01 dep");
rmSync(base, { recursive: true, force: true });
}
report();
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
});

View file

@ -1,3 +1,5 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
// ensureDbOpen — Tests that the lazy DB opener creates + migrates the database
// when .gsd/ exists with Markdown content but no gsd.db file.
//
@ -5,14 +7,11 @@
// "GSD database is not available" because ensureDbOpen only opened
// existing DB files but never created them.
import { createTestContext } from './test-helpers.ts';
import * as path from 'node:path';
import * as os from 'node:os';
import * as fs from 'node:fs';
import { closeDatabase, isDbAvailable, getDecisionById } from '../gsd-db.ts';
const { assertEq, assertTrue, report } = createTestContext();
function makeTmpDir(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ensure-db-'));
return dir;
@ -28,141 +27,134 @@ function cleanupDir(dir: string): void {
// ensureDbOpen creates DB + migrates when .gsd/ has Markdown
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n── ensureDbOpen: creates DB from Markdown ──');
describe('ensure-db-open', () => {
test('ensureDbOpen: creates DB from Markdown', async () => {
const tmpDir = makeTmpDir();
const gsdDir = path.join(tmpDir, '.gsd');
fs.mkdirSync(gsdDir, { recursive: true });
{
const tmpDir = makeTmpDir();
const gsdDir = path.join(tmpDir, '.gsd');
fs.mkdirSync(gsdDir, { recursive: true });
// Write a minimal DECISIONS.md so migration has content
const decisionsContent = `# Decisions
// Write a minimal DECISIONS.md so migration has content
const decisionsContent = `# Decisions
| # | When | Scope | Decision | Choice | Rationale | Revisable |
|---|------|-------|----------|--------|-----------|-----------|
| D001 | M001 | architecture | Use SQLite | SQLite | Sync API | Yes |
`;
fs.writeFileSync(path.join(gsdDir, 'DECISIONS.md'), decisionsContent);
| # | When | Scope | Decision | Choice | Rationale | Revisable |
|---|------|-------|----------|--------|-----------|-----------|
| D001 | M001 | architecture | Use SQLite | SQLite | Sync API | Yes |
`;
fs.writeFileSync(path.join(gsdDir, 'DECISIONS.md'), decisionsContent);
// Verify no DB file exists yet
const dbPath = path.join(gsdDir, 'gsd.db');
assert.ok(!fs.existsSync(dbPath), 'DB file should not exist before ensureDbOpen');
// Verify no DB file exists yet
const dbPath = path.join(gsdDir, 'gsd.db');
assertTrue(!fs.existsSync(dbPath), 'DB file should not exist before ensureDbOpen');
// Close any previously open DB
try { closeDatabase(); } catch { /* ok */ }
// Close any previously open DB
try { closeDatabase(); } catch { /* ok */ }
// Override process.cwd to point at tmpDir for ensureDbOpen
const origCwd = process.cwd;
process.cwd = () => tmpDir;
// Override process.cwd to point at tmpDir for ensureDbOpen
const origCwd = process.cwd;
process.cwd = () => tmpDir;
try {
// Dynamic import to get the freshest version
const { ensureDbOpen } = await import('../bootstrap/dynamic-tools.ts');
try {
// Dynamic import to get the freshest version
const { ensureDbOpen } = await import('../bootstrap/dynamic-tools.ts');
const result = await ensureDbOpen();
const result = await ensureDbOpen();
assert.ok(result === true, 'ensureDbOpen should return true when .gsd/ has Markdown');
assert.ok(fs.existsSync(dbPath), 'DB file should be created after ensureDbOpen');
assert.ok(isDbAvailable(), 'DB should be available after ensureDbOpen');
assertTrue(result === true, 'ensureDbOpen should return true when .gsd/ has Markdown');
assertTrue(fs.existsSync(dbPath), 'DB file should be created after ensureDbOpen');
assertTrue(isDbAvailable(), 'DB should be available after ensureDbOpen');
// Verify that Markdown migration actually ran
const decision = getDecisionById('D001');
assertTrue(decision !== null, 'D001 should be migrated from DECISIONS.md');
if (decision) {
assertEq(decision.scope, 'architecture', 'Migrated decision scope should match');
assertEq(decision.choice, 'SQLite', 'Migrated decision choice should match');
// Verify that Markdown migration actually ran
const decision = getDecisionById('D001');
assert.ok(decision !== null, 'D001 should be migrated from DECISIONS.md');
if (decision) {
assert.deepStrictEqual(decision.scope, 'architecture', 'Migrated decision scope should match');
assert.deepStrictEqual(decision.choice, 'SQLite', 'Migrated decision choice should match');
}
} finally {
process.cwd = origCwd;
closeDatabase();
cleanupDir(tmpDir);
}
} finally {
process.cwd = origCwd;
});
// ═══════════════════════════════════════════════════════════════════════════
// ensureDbOpen returns false when no .gsd/ exists
// ═══════════════════════════════════════════════════════════════════════════
test('ensureDbOpen: no .gsd/ returns false', async () => {
const tmpDir = makeTmpDir();
// No .gsd/ directory at all
try { closeDatabase(); } catch { /* ok */ }
const origCwd = process.cwd;
process.cwd = () => tmpDir;
try {
const { ensureDbOpen } = await import('../bootstrap/dynamic-tools.ts');
const result = await ensureDbOpen();
assert.ok(result === false, 'ensureDbOpen should return false when no .gsd/ exists');
assert.ok(!isDbAvailable(), 'DB should not be available');
} finally {
process.cwd = origCwd;
cleanupDir(tmpDir);
}
});
// ═══════════════════════════════════════════════════════════════════════════
// ensureDbOpen opens existing DB without re-migration
// ═══════════════════════════════════════════════════════════════════════════
test('ensureDbOpen: opens existing DB', async () => {
const tmpDir = makeTmpDir();
const gsdDir = path.join(tmpDir, '.gsd');
fs.mkdirSync(gsdDir, { recursive: true });
// Create a DB file first
const dbPath = path.join(gsdDir, 'gsd.db');
const { openDatabase } = await import('../gsd-db.ts');
openDatabase(dbPath);
closeDatabase();
cleanupDir(tmpDir);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// ensureDbOpen returns false when no .gsd/ exists
// ═══════════════════════════════════════════════════════════════════════════
assert.ok(fs.existsSync(dbPath), 'DB file should exist from manual create');
console.log('\n── ensureDbOpen: no .gsd/ returns false ──');
const origCwd = process.cwd;
process.cwd = () => tmpDir;
{
const tmpDir = makeTmpDir();
// No .gsd/ directory at all
try {
const { ensureDbOpen } = await import('../bootstrap/dynamic-tools.ts');
const result = await ensureDbOpen();
assert.ok(result === true, 'ensureDbOpen should open existing DB');
assert.ok(isDbAvailable(), 'DB should be available');
} finally {
process.cwd = origCwd;
closeDatabase();
cleanupDir(tmpDir);
}
});
try { closeDatabase(); } catch { /* ok */ }
const origCwd = process.cwd;
process.cwd = () => tmpDir;
// ═══════════════════════════════════════════════════════════════════════════
// ensureDbOpen returns false for empty .gsd/ (no Markdown, no DB)
// ═══════════════════════════════════════════════════════════════════════════
try {
const { ensureDbOpen } = await import('../bootstrap/dynamic-tools.ts');
const result = await ensureDbOpen();
assertTrue(result === false, 'ensureDbOpen should return false when no .gsd/ exists');
assertTrue(!isDbAvailable(), 'DB should not be available');
} finally {
process.cwd = origCwd;
cleanupDir(tmpDir);
}
}
test('ensureDbOpen: empty .gsd/ returns false', async () => {
const tmpDir = makeTmpDir();
fs.mkdirSync(path.join(tmpDir, '.gsd'), { recursive: true });
// .gsd/ exists but no DECISIONS.md, REQUIREMENTS.md, or milestones/
// ═══════════════════════════════════════════════════════════════════════════
// ensureDbOpen opens existing DB without re-migration
// ═══════════════════════════════════════════════════════════════════════════
try { closeDatabase(); } catch { /* ok */ }
const origCwd = process.cwd;
process.cwd = () => tmpDir;
console.log('\n── ensureDbOpen: opens existing DB ──');
try {
const { ensureDbOpen } = await import('../bootstrap/dynamic-tools.ts');
const result = await ensureDbOpen();
assert.ok(result === false, 'ensureDbOpen should return false for empty .gsd/');
} finally {
process.cwd = origCwd;
cleanupDir(tmpDir);
}
});
{
const tmpDir = makeTmpDir();
const gsdDir = path.join(tmpDir, '.gsd');
fs.mkdirSync(gsdDir, { recursive: true });
// ═══════════════════════════════════════════════════════════════════════════
// Create a DB file first
const dbPath = path.join(gsdDir, 'gsd.db');
const { openDatabase } = await import('../gsd-db.ts');
openDatabase(dbPath);
closeDatabase();
assertTrue(fs.existsSync(dbPath), 'DB file should exist from manual create');
const origCwd = process.cwd;
process.cwd = () => tmpDir;
try {
const { ensureDbOpen } = await import('../bootstrap/dynamic-tools.ts');
const result = await ensureDbOpen();
assertTrue(result === true, 'ensureDbOpen should open existing DB');
assertTrue(isDbAvailable(), 'DB should be available');
} finally {
process.cwd = origCwd;
closeDatabase();
cleanupDir(tmpDir);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// ensureDbOpen returns false for empty .gsd/ (no Markdown, no DB)
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n── ensureDbOpen: empty .gsd/ returns false ──');
{
const tmpDir = makeTmpDir();
fs.mkdirSync(path.join(tmpDir, '.gsd'), { recursive: true });
// .gsd/ exists but no DECISIONS.md, REQUIREMENTS.md, or milestones/
try { closeDatabase(); } catch { /* ok */ }
const origCwd = process.cwd;
process.cwd = () => tmpDir;
try {
const { ensureDbOpen } = await import('../bootstrap/dynamic-tools.ts');
const result = await ensureDbOpen();
assertTrue(result === false, 'ensureDbOpen should return false for empty .gsd/');
} finally {
process.cwd = origCwd;
cleanupDir(tmpDir);
}
}
// ═══════════════════════════════════════════════════════════════════════════
report();
});

View file

@ -1,3 +1,5 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
/**
* feature-branch-lifecycle.test.ts Integration tests for the feature-branch workflow.
*
@ -29,10 +31,6 @@ import { captureIntegrationBranch, getSliceBranchName } from "../worktree.ts";
import { writeIntegrationBranch, readIntegrationBranch } from "../git-service.ts";
import { nextMilestoneId, generateMilestoneSuffix } from "../guided-flow.ts";
import { createTestContext } from "./test-helpers.ts";
const { assertEq, assertTrue, assertMatch, report } = createTestContext();
// ─── Helpers ────────────────────────────────────────────────────────────────
function run(cmd: string, cwd: string): string {
@ -137,7 +135,7 @@ function addSliceToMilestone(
// ─── Tests ──────────────────────────────────────────────────────────────────
async function main(): Promise<void> {
describe('feature-branch-lifecycle-integration', async () => {
const savedCwd = process.cwd();
const tempDirs: string[] = [];
@ -154,14 +152,13 @@ async function main(): Promise<void> {
// Start on f-new-shiny-thing with uncommitted changes, create
// worktree, add slices, merge back. Assert main is untouched.
// ================================================================
console.log("\n=== Feature-branch lifecycle with unique milestone IDs ===");
{
test('Feature-branch lifecycle with unique milestone IDs', () => {
const featureBranch = "f-new-shiny-thing";
const repo = fresh(featureBranch);
// Generate a unique milestone ID (M001-xxxxxx format)
const milestoneId = nextMilestoneId([], true);
assertMatch(milestoneId, /^M001-[a-z0-9]{6}$/, "unique milestone ID format");
assert.match(milestoneId, /^M001-[a-z0-9]{6}$/, "unique milestone ID format");
// Snapshot main before anything happens
const mainShaBefore = headSha(repo, "main");
@ -174,8 +171,8 @@ async function main(): Promise<void> {
// Verify files are uncommitted
const statusBefore = run("git status --short", repo);
assertTrue(statusBefore.includes("wip-config.ts"), "wip-config.ts is uncommitted");
assertTrue(statusBefore.includes("wip-types.ts"), "wip-types.ts is uncommitted");
assert.ok(statusBefore.includes("wip-config.ts"), "wip-config.ts is uncommitted");
assert.ok(statusBefore.includes("wip-types.ts"), "wip-types.ts is uncommitted");
// ── Simulate what startAuto does: commit dirty state, capture integration branch ──
// startAuto bootstraps .gsd/ which commits .gsd/ files. It also calls
@ -198,7 +195,7 @@ async function main(): Promise<void> {
// Verify integration branch recorded
const recorded = readIntegrationBranch(repo, milestoneId);
assertEq(recorded, featureBranch, "integration branch recorded as feature branch");
assert.deepStrictEqual(recorded, featureBranch, "integration branch recorded as feature branch");
// Snapshot feature branch SHA after metadata commit (HEAD may have advanced)
const featureShaBeforeWorktree = headSha(repo, featureBranch);
@ -206,28 +203,28 @@ async function main(): Promise<void> {
// ── Create the auto-worktree ──
const wtPath = createAutoWorktree(repo, milestoneId);
tempDirs.push(wtPath);
assertTrue(existsSync(wtPath), "worktree directory created");
assert.ok(existsSync(wtPath), "worktree directory created");
// Worktree should be on milestone/<unique-id> branch
const wtBranch = run("git branch --show-current", wtPath);
assertEq(wtBranch, `milestone/${milestoneId}`, "worktree is on milestone branch");
assert.deepStrictEqual(wtBranch, `milestone/${milestoneId}`, "worktree is on milestone branch");
// Milestone branch should be rooted at the feature branch, not main
const milestoneBranchBase = headSha(repo, `milestone/${milestoneId}`);
assertEq(
assert.deepStrictEqual(
milestoneBranchBase,
featureShaBeforeWorktree,
"milestone branch starts from feature branch HEAD",
);
// Feature-branch-only file should be in the worktree
assertTrue(
assert.ok(
existsSync(join(wtPath, "feature-setup.ts")),
"feature branch file (feature-setup.ts) exists in worktree",
);
// Main should be completely untouched at this point
assertEq(headSha(repo, "main"), mainShaBefore, "main SHA unchanged after worktree creation");
assert.deepStrictEqual(headSha(repo, "main"), mainShaBefore, "main SHA unchanged after worktree creation");
// ── Do work in slices ──
addSliceToMilestone(wtPath, milestoneId, "S01", "Auth module", [
@ -250,62 +247,62 @@ async function main(): Promise<void> {
// ── Assert: feature branch received the merge ──
const currentBranch = run("git branch --show-current", repo);
assertEq(currentBranch, featureBranch, "repo is on feature branch after merge");
assert.deepStrictEqual(currentBranch, featureBranch, "repo is on feature branch after merge");
// Exactly one new commit on feature branch (the squash merge)
const featureLog = run(`git log --oneline ${featureBranch}`, repo);
assertTrue(
assert.ok(
featureLog.includes(`feat(${milestoneId})`),
"feature branch has milestone merge commit",
);
// Slice files are on the feature branch
assertTrue(existsSync(join(repo, "auth.ts")), "auth.ts on feature branch");
assertTrue(existsSync(join(repo, "dashboard.ts")), "dashboard.ts on feature branch");
assertTrue(existsSync(join(repo, "auth-utils.ts")), "auth-utils.ts on feature branch");
assert.ok(existsSync(join(repo, "auth.ts")), "auth.ts on feature branch");
assert.ok(existsSync(join(repo, "dashboard.ts")), "dashboard.ts on feature branch");
assert.ok(existsSync(join(repo, "auth-utils.ts")), "auth-utils.ts on feature branch");
// Original feature branch file still present
assertTrue(existsSync(join(repo, "feature-setup.ts")), "feature-setup.ts still on feature branch");
assert.ok(existsSync(join(repo, "feature-setup.ts")), "feature-setup.ts still on feature branch");
// Commit message is well-formed
assertTrue(result.commitMessage.includes("New shiny feature"), "commit message has milestone title");
assertTrue(result.commitMessage.includes("S01: Auth module"), "commit message lists S01");
assertTrue(result.commitMessage.includes("S02: Dashboard"), "commit message lists S02");
assertTrue(
assert.ok(result.commitMessage.includes("New shiny feature"), "commit message has milestone title");
assert.ok(result.commitMessage.includes("S01: Auth module"), "commit message lists S01");
assert.ok(result.commitMessage.includes("S02: Dashboard"), "commit message lists S02");
assert.ok(
result.commitMessage.includes(`milestone/${milestoneId}`),
"commit message references milestone branch with unique ID",
);
// ── Assert: main is COMPLETELY untouched ──
assertEq(headSha(repo, "main"), mainShaBefore, "main SHA unchanged after merge");
assertEq(commitCount(repo, "main"), mainCommitsBefore, "main commit count unchanged");
assert.deepStrictEqual(headSha(repo, "main"), mainShaBefore, "main SHA unchanged after merge");
assert.deepStrictEqual(commitCount(repo, "main"), mainCommitsBefore, "main commit count unchanged");
// Main should NOT have any of the milestone files
run("git checkout main", repo);
assertTrue(!existsSync(join(repo, "auth.ts")), "auth.ts NOT on main");
assertTrue(!existsSync(join(repo, "dashboard.ts")), "dashboard.ts NOT on main");
assertTrue(!existsSync(join(repo, "feature-setup.ts")), "feature-setup.ts NOT on main");
assert.ok(!existsSync(join(repo, "auth.ts")), "auth.ts NOT on main");
assert.ok(!existsSync(join(repo, "dashboard.ts")), "dashboard.ts NOT on main");
assert.ok(!existsSync(join(repo, "feature-setup.ts")), "feature-setup.ts NOT on main");
run(`git checkout ${featureBranch}`, repo);
// ── Assert: worktree cleaned up ──
const worktreeDir = join(repo, ".gsd", "worktrees", milestoneId);
assertTrue(!existsSync(worktreeDir), "worktree directory removed");
assert.ok(!existsSync(worktreeDir), "worktree directory removed");
// Milestone branch deleted
assertTrue(
assert.ok(
!branchExists(repo, `milestone/${milestoneId}`),
"milestone branch deleted after merge",
);
// Only expected branches remain
const branches = allBranches(repo);
assertTrue(branches.includes("main"), "main branch exists");
assertTrue(branches.includes(featureBranch), "feature branch exists");
assertTrue(
assert.ok(branches.includes("main"), "main branch exists");
assert.ok(branches.includes(featureBranch), "feature branch exists");
assert.ok(
!branches.some(b => b.startsWith("milestone/")),
"no milestone branches remain",
);
}
});
// ================================================================
// Test 2: Uncommitted .gsd/ planning files are available in worktree
@ -314,8 +311,7 @@ async function main(): Promise<void> {
// Planning artifacts should be carried into the worktree even if
// they weren't committed on the feature branch.
// ================================================================
console.log("\n=== Untracked planning files copied to worktree ===");
{
test('Untracked planning files copied to worktree', () => {
const featureBranch = "f-planning-test";
const repo = fresh(featureBranch);
const milestoneId = nextMilestoneId([], true);
@ -334,7 +330,7 @@ async function main(): Promise<void> {
writeFileSync(join(repo, ".gsd", "DECISIONS.md"), "# Decisions\n\n## D001\nTest decision.\n");
// These files are untracked
assertTrue(run("git status --short", repo).length > 0, "repo has untracked files");
assert.ok(run("git status --short", repo).length > 0, "repo has untracked files");
// Record integration branch and create worktree
writeIntegrationBranch(repo, milestoneId, featureBranch);
@ -344,11 +340,11 @@ async function main(): Promise<void> {
// With external state, worktree .gsd is a symlink to shared state.
// Verify symlink was created (planning files are shared, not copied).
const wtGsd = join(wtPath, ".gsd");
assertTrue(existsSync(wtGsd), "worktree .gsd exists (symlink or dir)");
assert.ok(existsSync(wtGsd), "worktree .gsd exists (symlink or dir)");
// Clean up: chdir back before teardown
process.chdir(savedCwd);
}
});
// ================================================================
// Test 3: Multiple milestones on the same feature branch
@ -356,8 +352,7 @@ async function main(): Promise<void> {
// Proves that unique IDs prevent collision when running successive
// milestones, and each merge lands on the feature branch.
// ================================================================
console.log("\n=== Multiple unique milestones on same feature branch ===");
{
test('Multiple unique milestones on same feature branch', () => {
const featureBranch = "f-multi-milestone";
const repo = fresh(featureBranch);
@ -377,12 +372,12 @@ async function main(): Promise<void> {
mergeMilestoneToMain(repo, mid1, makeRoadmap(mid1, "First", [{ id: "S01", title: "First milestone work" }]));
process.chdir(savedCwd);
assertTrue(existsSync(join(repo, "m1-feature.ts")), "m1 file on feature branch");
assert.ok(existsSync(join(repo, "m1-feature.ts")), "m1 file on feature branch");
// Second milestone — different unique ID
const mid2 = nextMilestoneId([mid1], true);
assertTrue(mid1 !== mid2, "second milestone has different ID");
assertMatch(mid2, /^M002-[a-z0-9]{6}$/, "second milestone is M002-xxxxxx");
assert.ok(mid1 !== mid2, "second milestone has different ID");
assert.match(mid2, /^M002-[a-z0-9]{6}$/, "second milestone is M002-xxxxxx");
mkdirSync(join(repo, ".gsd", "milestones", mid2), { recursive: true });
writeIntegrationBranch(repo, mid2, featureBranch);
@ -397,19 +392,19 @@ async function main(): Promise<void> {
process.chdir(savedCwd);
// Both milestone files on feature branch
assertTrue(existsSync(join(repo, "m1-feature.ts")), "m1 file still on feature branch");
assertTrue(existsSync(join(repo, "m2-feature.ts")), "m2 file on feature branch");
assert.ok(existsSync(join(repo, "m1-feature.ts")), "m1 file still on feature branch");
assert.ok(existsSync(join(repo, "m2-feature.ts")), "m2 file on feature branch");
// Main completely untouched
assertEq(headSha(repo, "main"), mainShaBefore, "main unchanged after two milestones");
assert.deepStrictEqual(headSha(repo, "main"), mainShaBefore, "main unchanged after two milestones");
// No milestone branches remain
const branches = allBranches(repo);
assertTrue(
assert.ok(
!branches.some(b => b.startsWith("milestone/")),
"no milestone branches remain after two milestones",
);
}
});
} finally {
process.chdir(savedCwd);
@ -417,8 +412,4 @@ async function main(): Promise<void> {
try { rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ }
}
}
report();
}
main();
});

View file

@ -1,3 +1,5 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
/**
* flag-file-db.test.ts Verify that REPLAN.md and REPLAN-TRIGGER.md
* flag-file detection in deriveStateFromDb() works from DB-only data
@ -24,10 +26,6 @@ import {
insertReplanHistory,
_getAdapter,
} from '../gsd-db.ts';
import { createTestContext } from './test-helpers.ts';
const { assertEq, assertTrue, report } = createTestContext();
// ─── Fixture Helpers ───────────────────────────────────────────────────────
function createFixtureBase(): string {
@ -78,11 +76,10 @@ const TASK_SUMMARY_STUB = `---\nblocker_discovered: false\n---\n# T01 Summary\nD
// Tests
// ═══════════════════════════════════════════════════════════════════════════
async function main(): Promise<void> {
describe('flag-file-db', async () => {
// ─── Test 1: blocker_discovered + no replan_history → replanning-slice ──
console.log('\n=== flag-file-db: blocker + no history → replanning ===');
{
test('flag-file-db: blocker + no history → replanning', async () => {
const base = createFixtureBase();
try {
// Write disk files needed by deriveStateFromDb (roadmap check, task dir check)
@ -91,7 +88,7 @@ async function main(): Promise<void> {
writeFile(base, 'milestones/M001/slices/S01/tasks/T02-PLAN.md', TASK_PLAN_STUB);
openDatabase(':memory:');
assertTrue(isDbAvailable(), 'test1: DB is available');
assert.ok(isDbAvailable(), 'test1: DB is available');
insertMilestone({ id: 'M001', title: 'Flag-File DB Test', status: 'active' });
insertSlice({ id: 'S01', milestoneId: 'M001', title: 'Test Slice', status: 'active', risk: 'low', depends: [] });
@ -102,20 +99,19 @@ async function main(): Promise<void> {
invalidateStateCache();
const state = await deriveStateFromDb(base);
assertEq(state.phase, 'replanning-slice', 'test1: phase is replanning-slice');
assertTrue(state.blockers.length > 0, 'test1: has blockers');
assertTrue(state.blockers[0]?.includes('blocker'), 'test1: blocker message mentions blocker');
assert.deepStrictEqual(state.phase, 'replanning-slice', 'test1: phase is replanning-slice');
assert.ok(state.blockers.length > 0, 'test1: has blockers');
assert.ok(state.blockers[0]?.includes('blocker'), 'test1: blocker message mentions blocker');
closeDatabase();
} finally {
closeDatabase();
cleanup(base);
}
}
});
// ─── Test 2: blocker_discovered + replan_history exists → loop protection → executing ──
console.log('\n=== flag-file-db: blocker + history → loop protection ===');
{
test('flag-file-db: blocker + history → loop protection', async () => {
const base = createFixtureBase();
try {
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
@ -139,18 +135,17 @@ async function main(): Promise<void> {
invalidateStateCache();
const state = await deriveStateFromDb(base);
assertEq(state.phase, 'executing', 'test2: phase is executing (loop protection)');
assert.deepStrictEqual(state.phase, 'executing', 'test2: phase is executing (loop protection)');
closeDatabase();
} finally {
closeDatabase();
cleanup(base);
}
}
});
// ─── Test 3: replan_triggered_at set + no replan_history → replanning-slice ──
console.log('\n=== flag-file-db: trigger column + no history → replanning ===');
{
test('flag-file-db: trigger column + no history → replanning', async () => {
const base = createFixtureBase();
try {
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
@ -173,20 +168,19 @@ async function main(): Promise<void> {
invalidateStateCache();
const state = await deriveStateFromDb(base);
assertEq(state.phase, 'replanning-slice', 'test3: phase is replanning-slice');
assertTrue(state.blockers.length > 0, 'test3: has blockers');
assertTrue(state.blockers[0]?.includes('Triage replan trigger'), 'test3: blocker message mentions triage trigger');
assert.deepStrictEqual(state.phase, 'replanning-slice', 'test3: phase is replanning-slice');
assert.ok(state.blockers.length > 0, 'test3: has blockers');
assert.ok(state.blockers[0]?.includes('Triage replan trigger'), 'test3: blocker message mentions triage trigger');
closeDatabase();
} finally {
closeDatabase();
cleanup(base);
}
}
});
// ─── Test 4: replan_triggered_at set + replan_history exists → loop protection ──
console.log('\n=== flag-file-db: trigger column + history → loop protection ===');
{
test('flag-file-db: trigger column + history → loop protection', async () => {
const base = createFixtureBase();
try {
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
@ -216,18 +210,17 @@ async function main(): Promise<void> {
invalidateStateCache();
const state = await deriveStateFromDb(base);
assertEq(state.phase, 'executing', 'test4: phase is executing (loop protection)');
assert.deepStrictEqual(state.phase, 'executing', 'test4: phase is executing (loop protection)');
closeDatabase();
} finally {
closeDatabase();
cleanup(base);
}
}
});
// ─── Test 5: no blocker, no trigger → phase is executing ──────────────
console.log('\n=== flag-file-db: no blocker, no trigger → executing ===');
{
test('flag-file-db: no blocker, no trigger → executing', async () => {
const base = createFixtureBase();
try {
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
@ -245,20 +238,19 @@ async function main(): Promise<void> {
invalidateStateCache();
const state = await deriveStateFromDb(base);
assertEq(state.phase, 'executing', 'test5: phase is executing');
assertEq(state.activeTask?.id, 'T02', 'test5: activeTask is T02');
assertEq(state.blockers.length, 0, 'test5: no blockers');
assert.deepStrictEqual(state.phase, 'executing', 'test5: phase is executing');
assert.deepStrictEqual(state.activeTask?.id, 'T02', 'test5: activeTask is T02');
assert.deepStrictEqual(state.blockers.length, 0, 'test5: no blockers');
closeDatabase();
} finally {
closeDatabase();
cleanup(base);
}
}
});
// ─── Diagnostic test: DB column inspection ──────────────────────────
console.log('\n=== flag-file-db: replan_triggered_at column is queryable ===');
{
test('flag-file-db: replan_triggered_at column is queryable', () => {
openDatabase(':memory:');
insertMilestone({ id: 'M001', title: 'Diagnostic', status: 'active' });
@ -269,7 +261,7 @@ async function main(): Promise<void> {
const before = adapter!.prepare(
"SELECT id, replan_triggered_at FROM slices WHERE milestone_id = :mid",
).get({ ":mid": "M001" }) as Record<string, unknown>;
assertEq(before["replan_triggered_at"], null, 'diagnostic: replan_triggered_at initially null');
assert.deepStrictEqual(before["replan_triggered_at"], null, 'diagnostic: replan_triggered_at initially null');
// After setting
adapter!.prepare(
@ -279,12 +271,8 @@ async function main(): Promise<void> {
const after = adapter!.prepare(
"SELECT id, replan_triggered_at FROM slices WHERE milestone_id = :mid",
).get({ ":mid": "M001" }) as Record<string, unknown>;
assertEq(after["replan_triggered_at"], "2025-01-01T00:00:00Z", 'diagnostic: replan_triggered_at is set');
assert.deepStrictEqual(after["replan_triggered_at"], "2025-01-01T00:00:00Z", 'diagnostic: replan_triggered_at is set');
closeDatabase();
}
report();
}
main();
});
});

View file

@ -1,4 +1,5 @@
import { createTestContext } from './test-helpers.ts';
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import * as path from 'node:path';
import * as os from 'node:os';
import * as fs from 'node:fs';
@ -13,8 +14,6 @@ import {
saveDecisionToDb,
} from '../db-writer.ts';
const { assertEq, assertTrue, report } = createTestContext();
// ═══════════════════════════════════════════════════════════════════════════
// Helpers
// ═══════════════════════════════════════════════════════════════════════════
@ -35,206 +34,199 @@ function cleanupDir(dir: string): void {
// Bug reproduction: freeform DECISIONS.md content destroyed (#2301)
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n── parseDecisionsTable silently drops freeform content ──');
describe('freeform-decisions', () => {
test('parseDecisionsTable silently drops freeform content', () => {
const freeform = `# Project Decisions
{
const freeform = `# Project Decisions
## Architecture
We decided to use a microservices architecture because monoliths don't scale.
## Architecture
We decided to use a microservices architecture because monoliths don't scale.
## Database
PostgreSQL was chosen for its reliability and JSONB support.
## Database
PostgreSQL was chosen for its reliability and JSONB support.
## Deployment
- Kubernetes for orchestration
- Helm charts for packaging
`;
## Deployment
- Kubernetes for orchestration
- Helm charts for packaging
`;
const parsed = parseDecisionsTable(freeform);
assert.deepStrictEqual(parsed.length, 0, 'freeform content yields zero parsed decisions (expected — it is not a table)');
});
const parsed = parseDecisionsTable(freeform);
assertEq(parsed.length, 0, 'freeform content yields zero parsed decisions (expected — it is not a table)');
}
test('saveDecisionToDb destroys freeform DECISIONS.md content', async () => {
const tmpDir = makeTmpDir();
const dbPath = path.join(tmpDir, '.gsd', 'gsd.db');
const mdPath = path.join(tmpDir, '.gsd', 'DECISIONS.md');
openDatabase(dbPath);
console.log('\n── saveDecisionToDb destroys freeform DECISIONS.md content ──');
const freeformContent = `# Project Decisions
{
const tmpDir = makeTmpDir();
const dbPath = path.join(tmpDir, '.gsd', 'gsd.db');
const mdPath = path.join(tmpDir, '.gsd', 'DECISIONS.md');
openDatabase(dbPath);
## Architecture
We decided to use a microservices architecture because monoliths don't scale.
const freeformContent = `# Project Decisions
## Database
PostgreSQL was chosen for its reliability and JSONB support.
## Architecture
We decided to use a microservices architecture because monoliths don't scale.
## Deployment
- Kubernetes for orchestration
- Helm charts for packaging
`;
## Database
PostgreSQL was chosen for its reliability and JSONB support.
// Pre-populate DECISIONS.md with freeform content
fs.writeFileSync(mdPath, freeformContent, 'utf-8');
## Deployment
- Kubernetes for orchestration
- Helm charts for packaging
`;
try {
// Save a new decision — this should NOT destroy the freeform content
const result = await saveDecisionToDb({
scope: 'testing',
decision: 'Use Jest for unit tests',
choice: 'Jest',
rationale: 'Well-known, good DX',
when_context: 'M001',
}, tmpDir);
// Pre-populate DECISIONS.md with freeform content
fs.writeFileSync(mdPath, freeformContent, 'utf-8');
assert.deepStrictEqual(result.id, 'D001', 'decision ID assigned correctly');
try {
// Save a new decision — this should NOT destroy the freeform content
const result = await saveDecisionToDb({
scope: 'testing',
decision: 'Use Jest for unit tests',
choice: 'Jest',
rationale: 'Well-known, good DX',
when_context: 'M001',
}, tmpDir);
// Read back the file
const afterContent = fs.readFileSync(mdPath, 'utf-8');
assertEq(result.id, 'D001', 'decision ID assigned correctly');
// The freeform content MUST still be present
assert.ok(
afterContent.includes('microservices architecture'),
'freeform architecture section preserved after saveDecisionToDb',
);
assert.ok(
afterContent.includes('PostgreSQL was chosen'),
'freeform database section preserved after saveDecisionToDb',
);
assert.ok(
afterContent.includes('Kubernetes for orchestration'),
'freeform deployment section preserved after saveDecisionToDb',
);
// Read back the file
const afterContent = fs.readFileSync(mdPath, 'utf-8');
// The new decision MUST also be present
assert.ok(
afterContent.includes('D001'),
'new decision D001 present in file',
);
assert.ok(
afterContent.includes('Use Jest for unit tests'),
'new decision text present in file',
);
// The freeform content MUST still be present
assertTrue(
afterContent.includes('microservices architecture'),
'freeform architecture section preserved after saveDecisionToDb',
);
assertTrue(
afterContent.includes('PostgreSQL was chosen'),
'freeform database section preserved after saveDecisionToDb',
);
assertTrue(
afterContent.includes('Kubernetes for orchestration'),
'freeform deployment section preserved after saveDecisionToDb',
);
// Save a second decision — freeform content must still survive
const result2 = await saveDecisionToDb({
scope: 'ci',
decision: 'Use GitHub Actions for CI',
choice: 'GitHub Actions',
rationale: 'Native integration',
when_context: 'M001',
}, tmpDir);
// The new decision MUST also be present
assertTrue(
afterContent.includes('D001'),
'new decision D001 present in file',
);
assertTrue(
afterContent.includes('Use Jest for unit tests'),
'new decision text present in file',
);
assert.deepStrictEqual(result2.id, 'D002', 'second decision ID assigned correctly');
// Save a second decision — freeform content must still survive
const result2 = await saveDecisionToDb({
scope: 'ci',
decision: 'Use GitHub Actions for CI',
choice: 'GitHub Actions',
rationale: 'Native integration',
when_context: 'M001',
}, tmpDir);
const afterContent2 = fs.readFileSync(mdPath, 'utf-8');
assertEq(result2.id, 'D002', 'second decision ID assigned correctly');
assert.ok(
afterContent2.includes('microservices architecture'),
'freeform content still preserved after second save',
);
assert.ok(
afterContent2.includes('D001'),
'first decision still present after second save',
);
assert.ok(
afterContent2.includes('D002'),
'second decision present after second save',
);
assert.ok(
afterContent2.includes('Use GitHub Actions for CI'),
'second decision text present in file',
);
} finally {
closeDatabase();
cleanupDir(tmpDir);
}
});
const afterContent2 = fs.readFileSync(mdPath, 'utf-8');
test('saveDecisionToDb with table-format DECISIONS.md still regenerates normally', async () => {
const tmpDir = makeTmpDir();
const dbPath = path.join(tmpDir, '.gsd', 'gsd.db');
const mdPath = path.join(tmpDir, '.gsd', 'DECISIONS.md');
openDatabase(dbPath);
assertTrue(
afterContent2.includes('microservices architecture'),
'freeform content still preserved after second save',
);
assertTrue(
afterContent2.includes('D001'),
'first decision still present after second save',
);
assertTrue(
afterContent2.includes('D002'),
'second decision present after second save',
);
assertTrue(
afterContent2.includes('Use GitHub Actions for CI'),
'second decision text present in file',
);
} finally {
closeDatabase();
cleanupDir(tmpDir);
}
}
// Pre-populate with canonical table format
const tableContent = `# Decisions Register
console.log('\n── saveDecisionToDb with table-format DECISIONS.md still regenerates normally ──');
<!-- Append-only. Never edit or remove existing rows.
To reverse a decision, add a new row that supersedes it.
Read this file at the start of any planning or research phase. -->
{
const tmpDir = makeTmpDir();
const dbPath = path.join(tmpDir, '.gsd', 'gsd.db');
const mdPath = path.join(tmpDir, '.gsd', 'DECISIONS.md');
openDatabase(dbPath);
| # | When | Scope | Decision | Choice | Rationale | Revisable? | Made By |
|---|------|-------|----------|--------|-----------|------------|---------|
| D001 | M001 | arch | Use REST API | REST | Simpler | Yes | human |
`;
// Pre-populate with canonical table format
const tableContent = `# Decisions Register
fs.writeFileSync(mdPath, tableContent, 'utf-8');
<!-- Append-only. Never edit or remove existing rows.
To reverse a decision, add a new row that supersedes it.
Read this file at the start of any planning or research phase. -->
try {
const result = await saveDecisionToDb({
scope: 'testing',
decision: 'Use Vitest',
choice: 'Vitest',
rationale: 'Fast',
when_context: 'M001',
}, tmpDir);
| # | When | Scope | Decision | Choice | Rationale | Revisable? | Made By |
|---|------|-------|----------|--------|-----------|------------|---------|
| D001 | M001 | arch | Use REST API | REST | Simpler | Yes | human |
`;
// The pre-existing table decision was NOT in DB, so it won't appear after regen.
// But the new decision should be there.
assert.deepStrictEqual(result.id, 'D001', 'gets D001 since DB was empty');
fs.writeFileSync(mdPath, tableContent, 'utf-8');
const afterContent = fs.readFileSync(mdPath, 'utf-8');
// Table-format file gets fully regenerated — this is the normal path
assert.ok(
afterContent.includes('# Decisions Register'),
'table-format file still has header after save',
);
assert.ok(
afterContent.includes('Use Vitest'),
'new decision present in regenerated table',
);
} finally {
closeDatabase();
cleanupDir(tmpDir);
}
});
try {
const result = await saveDecisionToDb({
scope: 'testing',
decision: 'Use Vitest',
choice: 'Vitest',
rationale: 'Fast',
when_context: 'M001',
}, tmpDir);
test('saveDecisionToDb with no existing DECISIONS.md creates table', async () => {
const tmpDir = makeTmpDir();
const dbPath = path.join(tmpDir, '.gsd', 'gsd.db');
const mdPath = path.join(tmpDir, '.gsd', 'DECISIONS.md');
openDatabase(dbPath);
// The pre-existing table decision was NOT in DB, so it won't appear after regen.
// But the new decision should be there.
assertEq(result.id, 'D001', 'gets D001 since DB was empty');
// No DECISIONS.md exists at all
assert.ok(!fs.existsSync(mdPath), 'DECISIONS.md does not exist initially');
const afterContent = fs.readFileSync(mdPath, 'utf-8');
// Table-format file gets fully regenerated — this is the normal path
assertTrue(
afterContent.includes('# Decisions Register'),
'table-format file still has header after save',
);
assertTrue(
afterContent.includes('Use Vitest'),
'new decision present in regenerated table',
);
} finally {
closeDatabase();
cleanupDir(tmpDir);
}
}
try {
const result = await saveDecisionToDb({
scope: 'arch',
decision: 'Brand new decision',
choice: 'Option A',
rationale: 'Best fit',
}, tmpDir);
console.log('\n── saveDecisionToDb with no existing DECISIONS.md creates table ──');
assert.deepStrictEqual(result.id, 'D001', 'first decision gets D001');
assert.ok(fs.existsSync(mdPath), 'DECISIONS.md created');
{
const tmpDir = makeTmpDir();
const dbPath = path.join(tmpDir, '.gsd', 'gsd.db');
const mdPath = path.join(tmpDir, '.gsd', 'DECISIONS.md');
openDatabase(dbPath);
const content = fs.readFileSync(mdPath, 'utf-8');
assert.ok(content.includes('# Decisions Register'), 'new file has header');
assert.ok(content.includes('Brand new decision'), 'new file has decision');
} finally {
closeDatabase();
cleanupDir(tmpDir);
}
});
// No DECISIONS.md exists at all
assertTrue(!fs.existsSync(mdPath), 'DECISIONS.md does not exist initially');
// ═══════════════════════════════════════════════════════════════════════════
try {
const result = await saveDecisionToDb({
scope: 'arch',
decision: 'Brand new decision',
choice: 'Option A',
rationale: 'Best fit',
}, tmpDir);
assertEq(result.id, 'D001', 'first decision gets D001');
assertTrue(fs.existsSync(mdPath), 'DECISIONS.md created');
const content = fs.readFileSync(mdPath, 'utf-8');
assertTrue(content.includes('# Decisions Register'), 'new file has header');
assertTrue(content.includes('Brand new decision'), 'new file has decision');
} finally {
closeDatabase();
cleanupDir(tmpDir);
}
}
// ═══════════════════════════════════════════════════════════════════════════
report();
});

View file

@ -1,3 +1,5 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
/**
* Regression tests for #1997: git locale not forced to C.
*
@ -13,10 +15,6 @@ import { execFileSync } from "node:child_process";
import { GIT_NO_PROMPT_ENV } from "../git-constants.ts";
import { nativeAddAllWithExclusions } from "../native-git-bridge.ts";
import { RUNTIME_EXCLUSION_PATHS } from "../git-service.ts";
import { createTestContext } from "./test-helpers.ts";
const { assertEq, assertTrue, report } = createTestContext();
function git(cwd: string, ...args: string[]): string {
return execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
}
@ -39,27 +37,24 @@ function createFile(base: string, relPath: string, content: string): void {
writeFileSync(full, content);
}
async function main(): Promise<void> {
describe('git-locale', async () => {
// ─── GIT_NO_PROMPT_ENV includes LC_ALL=C ─────────────────────────────
console.log("\n=== GIT_NO_PROMPT_ENV includes LC_ALL=C ===");
assertEq(
assert.deepStrictEqual(
GIT_NO_PROMPT_ENV.LC_ALL,
"C",
"GIT_NO_PROMPT_ENV must set LC_ALL to 'C' to force English git output"
);
assertTrue(
assert.ok(
"GIT_TERMINAL_PROMPT" in GIT_NO_PROMPT_ENV,
"GIT_NO_PROMPT_ENV still contains GIT_TERMINAL_PROMPT"
);
// ─── nativeAddAllWithExclusions: non-English locale does not throw ───
console.log("\n=== nativeAddAllWithExclusions: non-English locale does not throw ===");
{
test('nativeAddAllWithExclusions: non-English locale does not throw', () => {
// Simulate what happens on a German system: .gsd is gitignored,
// exclusion pathspecs trigger an advisory warning exit code 1.
// With LC_ALL=C the English stderr guard should match and suppress.
@ -89,22 +84,20 @@ async function main(): Promise<void> {
if (origLang !== undefined) process.env.LANG = origLang;
else delete process.env.LANG;
assertTrue(
assert.ok(
!threw,
"nativeAddAllWithExclusions must not throw on non-English locale when .gsd is gitignored (#1997)"
);
const staged = git(repo, "diff", "--cached", "--name-only");
assertTrue(staged.includes("src/app.ts"), "real file staged despite German locale");
assert.ok(staged.includes("src/app.ts"), "real file staged despite German locale");
rmSync(repo, { recursive: true, force: true });
}
});
// ─── nativeMergeSquash: env is passed (merge-squash stderr is English) ─
console.log("\n=== nativeMergeSquash fallback uses GIT_NO_PROMPT_ENV ===");
{
test('nativeMergeSquash fallback uses GIT_NO_PROMPT_ENV', () => {
// We verify indirectly: the source code must pass env: GIT_NO_PROMPT_ENV.
// Read the source and check for the pattern. This is a static check.
const src = readFileSync(
@ -114,20 +107,13 @@ async function main(): Promise<void> {
// Find the nativeMergeSquash function and check it uses GIT_NO_PROMPT_ENV
const fnStart = src.indexOf("export function nativeMergeSquash");
assertTrue(fnStart !== -1, "nativeMergeSquash function exists in source");
assert.ok(fnStart !== -1, "nativeMergeSquash function exists in source");
const fnBody = src.slice(fnStart, src.indexOf("\nexport function", fnStart + 1));
const hasEnv = fnBody.includes("env: GIT_NO_PROMPT_ENV");
assertTrue(
assert.ok(
hasEnv,
"nativeMergeSquash fallback must pass env: GIT_NO_PROMPT_ENV to execFileSync (#1997)"
);
}
report();
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
});

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,5 @@
import { createTestContext } from './test-helpers.ts';
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
@ -18,8 +19,6 @@ import {
_resetProvider,
} from '../gsd-db.ts';
const { assertEq, assertTrue, report } = createTestContext();
// ═══════════════════════════════════════════════════════════════════════════
// Helper: create a temp file path for file-backed DB tests
// ═══════════════════════════════════════════════════════════════════════════
@ -47,314 +46,306 @@ function cleanup(dbPath: string): void {
// gsd-db tests
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n=== gsd-db: provider detection ===');
{
const provider = getDbProvider();
assertTrue(provider !== null, 'provider should be non-null');
assertTrue(
provider === 'node:sqlite' || provider === 'better-sqlite3',
`provider should be a known name, got: ${provider}`,
);
}
console.log('\n=== gsd-db: fresh DB schema init (memory) ===');
{
const ok = openDatabase(':memory:');
assertTrue(ok, 'openDatabase should return true');
assertTrue(isDbAvailable(), 'isDbAvailable should be true after open');
// Check schema_version table
const adapter = _getAdapter()!;
const version = adapter.prepare('SELECT MAX(version) as version FROM schema_version').get();
assertEq(version?.['version'], 10, 'schema version should be 10');
// Check tables exist by querying them
const dRows = adapter.prepare('SELECT count(*) as cnt FROM decisions').get();
assertEq(dRows?.['cnt'], 0, 'decisions table should exist and be empty');
const rRows = adapter.prepare('SELECT count(*) as cnt FROM requirements').get();
assertEq(rRows?.['cnt'], 0, 'requirements table should exist and be empty');
closeDatabase();
assertTrue(!isDbAvailable(), 'isDbAvailable should be false after close');
}
console.log('\n=== gsd-db: double-init idempotency ===');
{
const dbPath = tempDbPath();
openDatabase(dbPath);
// Insert a decision so we can verify it survives re-init
insertDecision({
id: 'D001',
when_context: 'test',
scope: 'global',
decision: 'test decision',
choice: 'option A',
rationale: 'because',
revisable: 'yes',
made_by: 'agent',
superseded_by: null,
describe('gsd-db', () => {
test('gsd-db: provider detection', () => {
const provider = getDbProvider();
assert.ok(provider !== null, 'provider should be non-null');
assert.ok(
provider === 'node:sqlite' || provider === 'better-sqlite3',
`provider should be a known name, got: ${provider}`,
);
});
closeDatabase();
test('gsd-db: fresh DB schema init (memory)', () => {
const ok = openDatabase(':memory:');
assert.ok(ok, 'openDatabase should return true');
assert.ok(isDbAvailable(), 'isDbAvailable should be true after open');
// Re-open same DB — schema init should be idempotent
openDatabase(dbPath);
const d = getDecisionById('D001');
assertTrue(d !== null, 'decision should survive re-init');
assertEq(d?.id, 'D001', 'decision ID preserved after re-init');
// Check schema_version table
const adapter = _getAdapter()!;
const version = adapter.prepare('SELECT MAX(version) as version FROM schema_version').get();
assert.deepStrictEqual(version?.['version'], 10, 'schema version should be 10');
// Schema version should still be 1 (not duplicated)
const adapter = _getAdapter()!;
const versions = adapter.prepare('SELECT count(*) as cnt FROM schema_version').get();
assertEq(versions?.['cnt'], 1, 'schema_version should have exactly 1 row after double-init');
// Check tables exist by querying them
const dRows = adapter.prepare('SELECT count(*) as cnt FROM decisions').get();
assert.deepStrictEqual(dRows?.['cnt'], 0, 'decisions table should exist and be empty');
cleanup(dbPath);
}
const rRows = adapter.prepare('SELECT count(*) as cnt FROM requirements').get();
assert.deepStrictEqual(rRows?.['cnt'], 0, 'requirements table should exist and be empty');
console.log('\n=== gsd-db: insert + get decision ===');
{
openDatabase(':memory:');
insertDecision({
id: 'D042',
when_context: 'during sprint 3',
scope: 'M001/S02',
decision: 'use SQLite for storage',
choice: 'node:sqlite',
rationale: 'built-in, zero deps',
revisable: 'yes, if perf insufficient',
made_by: 'agent',
superseded_by: null,
closeDatabase();
assert.ok(!isDbAvailable(), 'isDbAvailable should be false after close');
});
const d = getDecisionById('D042');
assertTrue(d !== null, 'should find inserted decision');
assertEq(d?.id, 'D042', 'decision id');
assertEq(d?.scope, 'M001/S02', 'decision scope');
assertEq(d?.choice, 'node:sqlite', 'decision choice');
assertTrue(typeof d?.seq === 'number' && d.seq > 0, 'seq should be auto-assigned positive number');
assertEq(d?.superseded_by, null, 'superseded_by should be null');
test('gsd-db: double-init idempotency', () => {
const dbPath = tempDbPath();
openDatabase(dbPath);
// Non-existent
const missing = getDecisionById('D999');
assertEq(missing, null, 'non-existent decision returns null');
closeDatabase();
}
console.log('\n=== gsd-db: insert + get requirement ===');
{
openDatabase(':memory:');
insertRequirement({
id: 'R007',
class: 'functional',
status: 'active',
description: 'System must persist decisions',
why: 'decisions inform future agents',
source: 'M001-CONTEXT',
primary_owner: 'S01',
supporting_slices: 'S02, S03',
validation: 'insert and query roundtrip',
notes: 'high priority',
full_content: 'Full text of requirement...',
superseded_by: null,
});
const r = getRequirementById('R007');
assertTrue(r !== null, 'should find inserted requirement');
assertEq(r?.id, 'R007', 'requirement id');
assertEq(r?.class, 'functional', 'requirement class');
assertEq(r?.status, 'active', 'requirement status');
assertEq(r?.primary_owner, 'S01', 'requirement primary_owner');
assertEq(r?.superseded_by, null, 'superseded_by should be null');
// Non-existent
const missing = getRequirementById('R999');
assertEq(missing, null, 'non-existent requirement returns null');
closeDatabase();
}
console.log('\n=== gsd-db: active_decisions view excludes superseded ===');
{
openDatabase(':memory:');
insertDecision({
id: 'D001',
when_context: 'early',
scope: 'global',
decision: 'use JSON files',
choice: 'JSON',
rationale: 'simple',
revisable: 'yes',
made_by: 'agent',
superseded_by: 'D002', // superseded!
});
insertDecision({
id: 'D002',
when_context: 'later',
scope: 'global',
decision: 'use SQLite',
choice: 'SQLite',
rationale: 'better querying',
revisable: 'yes',
made_by: 'agent',
superseded_by: null, // active
});
insertDecision({
id: 'D003',
when_context: 'same time',
scope: 'local',
decision: 'use WAL mode',
choice: 'WAL',
rationale: 'concurrent reads',
revisable: 'no',
made_by: 'agent',
superseded_by: null, // active
});
const active = getActiveDecisions();
assertEq(active.length, 2, 'active_decisions should return 2 (not the superseded one)');
const ids = active.map(d => d.id).sort();
assertEq(ids, ['D002', 'D003'], 'active decisions should be D002 and D003');
// Verify D001 is still in the raw table
const d1 = getDecisionById('D001');
assertTrue(d1 !== null, 'superseded decision still exists in raw table');
assertEq(d1?.superseded_by, 'D002', 'superseded_by is set');
closeDatabase();
}
console.log('\n=== gsd-db: active_requirements view excludes superseded ===');
{
openDatabase(':memory:');
insertRequirement({
id: 'R001',
class: 'functional',
status: 'active',
description: 'old requirement',
why: 'was needed',
source: 'M001',
primary_owner: 'S01',
supporting_slices: '',
validation: 'test',
notes: '',
full_content: '',
superseded_by: 'R002', // superseded!
});
insertRequirement({
id: 'R002',
class: 'functional',
status: 'active',
description: 'new requirement',
why: 'replaces R001',
source: 'M001',
primary_owner: 'S01',
supporting_slices: '',
validation: 'test',
notes: '',
full_content: '',
superseded_by: null, // active
});
const active = getActiveRequirements();
assertEq(active.length, 1, 'active_requirements should return 1');
assertEq(active[0]?.id, 'R002', 'only R002 should be active');
// R001 still in raw table
const r1 = getRequirementById('R001');
assertTrue(r1 !== null, 'superseded requirement still in raw table');
closeDatabase();
}
console.log('\n=== gsd-db: WAL mode on file-backed DB ===');
{
const dbPath = tempDbPath();
openDatabase(dbPath);
const adapter = _getAdapter()!;
const mode = adapter.prepare('PRAGMA journal_mode').get();
assertEq(mode?.['journal_mode'], 'wal', 'journal_mode should be wal for file-backed DB');
cleanup(dbPath);
}
console.log('\n=== gsd-db: transaction rollback on error ===');
{
openDatabase(':memory:');
// Insert a decision normally
insertDecision({
id: 'D010',
when_context: 'test',
scope: 'test',
decision: 'test',
choice: 'test',
rationale: 'test',
revisable: 'test',
made_by: 'agent',
superseded_by: null,
});
// Try a transaction that fails — the insert inside should be rolled back
let threw = false;
try {
transaction(() => {
insertDecision({
id: 'D011',
when_context: 'should be rolled back',
scope: 'test',
decision: 'test',
choice: 'test',
rationale: 'test',
revisable: 'test',
made_by: 'agent',
superseded_by: null,
});
throw new Error('intentional failure');
// Insert a decision so we can verify it survives re-init
insertDecision({
id: 'D001',
when_context: 'test',
scope: 'global',
decision: 'test decision',
choice: 'option A',
rationale: 'because',
revisable: 'yes',
made_by: 'agent',
superseded_by: null,
});
} catch (err) {
if ((err as Error).message === 'intentional failure') {
threw = true;
closeDatabase();
// Re-open same DB — schema init should be idempotent
openDatabase(dbPath);
const d = getDecisionById('D001');
assert.ok(d !== null, 'decision should survive re-init');
assert.deepStrictEqual(d?.id, 'D001', 'decision ID preserved after re-init');
// Schema version should still be 1 (not duplicated)
const adapter = _getAdapter()!;
const versions = adapter.prepare('SELECT count(*) as cnt FROM schema_version').get();
assert.deepStrictEqual(versions?.['cnt'], 1, 'schema_version should have exactly 1 row after double-init');
cleanup(dbPath);
});
test('gsd-db: insert + get decision', () => {
openDatabase(':memory:');
insertDecision({
id: 'D042',
when_context: 'during sprint 3',
scope: 'M001/S02',
decision: 'use SQLite for storage',
choice: 'node:sqlite',
rationale: 'built-in, zero deps',
revisable: 'yes, if perf insufficient',
made_by: 'agent',
superseded_by: null,
});
const d = getDecisionById('D042');
assert.ok(d !== null, 'should find inserted decision');
assert.deepStrictEqual(d?.id, 'D042', 'decision id');
assert.deepStrictEqual(d?.scope, 'M001/S02', 'decision scope');
assert.deepStrictEqual(d?.choice, 'node:sqlite', 'decision choice');
assert.ok(typeof d?.seq === 'number' && d.seq > 0, 'seq should be auto-assigned positive number');
assert.deepStrictEqual(d?.superseded_by, null, 'superseded_by should be null');
// Non-existent
const missing = getDecisionById('D999');
assert.deepStrictEqual(missing, null, 'non-existent decision returns null');
closeDatabase();
});
test('gsd-db: insert + get requirement', () => {
openDatabase(':memory:');
insertRequirement({
id: 'R007',
class: 'functional',
status: 'active',
description: 'System must persist decisions',
why: 'decisions inform future agents',
source: 'M001-CONTEXT',
primary_owner: 'S01',
supporting_slices: 'S02, S03',
validation: 'insert and query roundtrip',
notes: 'high priority',
full_content: 'Full text of requirement...',
superseded_by: null,
});
const r = getRequirementById('R007');
assert.ok(r !== null, 'should find inserted requirement');
assert.deepStrictEqual(r?.id, 'R007', 'requirement id');
assert.deepStrictEqual(r?.class, 'functional', 'requirement class');
assert.deepStrictEqual(r?.status, 'active', 'requirement status');
assert.deepStrictEqual(r?.primary_owner, 'S01', 'requirement primary_owner');
assert.deepStrictEqual(r?.superseded_by, null, 'superseded_by should be null');
// Non-existent
const missing = getRequirementById('R999');
assert.deepStrictEqual(missing, null, 'non-existent requirement returns null');
closeDatabase();
});
test('gsd-db: active_decisions view excludes superseded', () => {
openDatabase(':memory:');
insertDecision({
id: 'D001',
when_context: 'early',
scope: 'global',
decision: 'use JSON files',
choice: 'JSON',
rationale: 'simple',
revisable: 'yes',
made_by: 'agent',
superseded_by: 'D002', // superseded!
});
insertDecision({
id: 'D002',
when_context: 'later',
scope: 'global',
decision: 'use SQLite',
choice: 'SQLite',
rationale: 'better querying',
revisable: 'yes',
made_by: 'agent',
superseded_by: null, // active
});
insertDecision({
id: 'D003',
when_context: 'same time',
scope: 'local',
decision: 'use WAL mode',
choice: 'WAL',
rationale: 'concurrent reads',
revisable: 'no',
made_by: 'agent',
superseded_by: null, // active
});
const active = getActiveDecisions();
assert.deepStrictEqual(active.length, 2, 'active_decisions should return 2 (not the superseded one)');
const ids = active.map(d => d.id).sort();
assert.deepStrictEqual(ids, ['D002', 'D003'], 'active decisions should be D002 and D003');
// Verify D001 is still in the raw table
const d1 = getDecisionById('D001');
assert.ok(d1 !== null, 'superseded decision still exists in raw table');
assert.deepStrictEqual(d1?.superseded_by, 'D002', 'superseded_by is set');
closeDatabase();
});
test('gsd-db: active_requirements view excludes superseded', () => {
openDatabase(':memory:');
insertRequirement({
id: 'R001',
class: 'functional',
status: 'active',
description: 'old requirement',
why: 'was needed',
source: 'M001',
primary_owner: 'S01',
supporting_slices: '',
validation: 'test',
notes: '',
full_content: '',
superseded_by: 'R002', // superseded!
});
insertRequirement({
id: 'R002',
class: 'functional',
status: 'active',
description: 'new requirement',
why: 'replaces R001',
source: 'M001',
primary_owner: 'S01',
supporting_slices: '',
validation: 'test',
notes: '',
full_content: '',
superseded_by: null, // active
});
const active = getActiveRequirements();
assert.deepStrictEqual(active.length, 1, 'active_requirements should return 1');
assert.deepStrictEqual(active[0]?.id, 'R002', 'only R002 should be active');
// R001 still in raw table
const r1 = getRequirementById('R001');
assert.ok(r1 !== null, 'superseded requirement still in raw table');
closeDatabase();
});
test('gsd-db: WAL mode on file-backed DB', () => {
const dbPath = tempDbPath();
openDatabase(dbPath);
const adapter = _getAdapter()!;
const mode = adapter.prepare('PRAGMA journal_mode').get();
assert.deepStrictEqual(mode?.['journal_mode'], 'wal', 'journal_mode should be wal for file-backed DB');
cleanup(dbPath);
});
test('gsd-db: transaction rollback on error', () => {
openDatabase(':memory:');
// Insert a decision normally
insertDecision({
id: 'D010',
when_context: 'test',
scope: 'test',
decision: 'test',
choice: 'test',
rationale: 'test',
revisable: 'test',
made_by: 'agent',
superseded_by: null,
});
// Try a transaction that fails — the insert inside should be rolled back
let threw = false;
try {
transaction(() => {
insertDecision({
id: 'D011',
when_context: 'should be rolled back',
scope: 'test',
decision: 'test',
choice: 'test',
rationale: 'test',
revisable: 'test',
made_by: 'agent',
superseded_by: null,
});
throw new Error('intentional failure');
});
} catch (err) {
if ((err as Error).message === 'intentional failure') {
threw = true;
}
}
}
assertTrue(threw, 'transaction should re-throw the error');
const d11 = getDecisionById('D011');
assertEq(d11, null, 'D011 should be rolled back (not found)');
assert.ok(threw, 'transaction should re-throw the error');
const d11 = getDecisionById('D011');
assert.deepStrictEqual(d11, null, 'D011 should be rolled back (not found)');
// D010 should still be there
const d10 = getDecisionById('D010');
assertTrue(d10 !== null, 'D010 should survive the failed transaction');
// D010 should still be there
const d10 = getDecisionById('D010');
assert.ok(d10 !== null, 'D010 should survive the failed transaction');
closeDatabase();
}
closeDatabase();
});
console.log('\n=== gsd-db: query wrappers return null/empty when DB unavailable ===');
{
// Ensure DB is closed
closeDatabase();
assertTrue(!isDbAvailable(), 'DB should not be available');
test('gsd-db: query wrappers return null/empty when DB unavailable', () => {
// Ensure DB is closed
closeDatabase();
assert.ok(!isDbAvailable(), 'DB should not be available');
const d = getDecisionById('D001');
assertEq(d, null, 'getDecisionById returns null when DB closed');
const d = getDecisionById('D001');
assert.deepStrictEqual(d, null, 'getDecisionById returns null when DB closed');
const r = getRequirementById('R001');
assertEq(r, null, 'getRequirementById returns null when DB closed');
const r = getRequirementById('R001');
assert.deepStrictEqual(r, null, 'getRequirementById returns null when DB closed');
const ad = getActiveDecisions();
assertEq(ad, [], 'getActiveDecisions returns [] when DB closed');
const ad = getActiveDecisions();
assert.deepStrictEqual(ad, [], 'getActiveDecisions returns [] when DB closed');
const ar = getActiveRequirements();
assertEq(ar, [], 'getActiveRequirements returns [] when DB closed');
}
const ar = getActiveRequirements();
assert.deepStrictEqual(ar, [], 'getActiveRequirements returns [] when DB closed');
});
// ─── Final Report ──────────────────────────────────────────────────────────
report();
// ─── Final Report ──────────────────────────────────────────────────────────
});

View file

@ -1,125 +1,114 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
// gsd-inspect — Tests for /gsd inspect output formatting
//
// Tests the pure formatInspectOutput function with known data.
import { createTestContext } from './test-helpers.ts';
import { formatInspectOutput, type InspectData } from '../commands-inspect.ts';
const { assertEq, assertTrue, assertMatch, report } = createTestContext();
describe('gsd-inspect', () => {
test('full output formatting', () => {
const data: InspectData = {
schemaVersion: 2,
counts: { decisions: 12, requirements: 8, artifacts: 3 },
recentDecisions: [
{ id: "D012", decision: "Use SQLite for persistence", choice: "node:sqlite with fallback" },
{ id: "D011", decision: "Markdown dual-write", choice: "DB-first then regenerate" },
],
recentRequirements: [
{ id: "R015", status: "active", description: "Commands register via pi.registerCommand" },
{ id: "R014", status: "active", description: "DB writes use upsert pattern" },
],
};
// ── formats output with schema version, counts, and recent entries ──
console.log("# === gsd-inspect: full output formatting ===");
{
const data: InspectData = {
schemaVersion: 2,
counts: { decisions: 12, requirements: 8, artifacts: 3 },
recentDecisions: [
{ id: "D012", decision: "Use SQLite for persistence", choice: "node:sqlite with fallback" },
{ id: "D011", decision: "Markdown dual-write", choice: "DB-first then regenerate" },
],
recentRequirements: [
{ id: "R015", status: "active", description: "Commands register via pi.registerCommand" },
{ id: "R014", status: "active", description: "DB writes use upsert pattern" },
],
};
const output = formatInspectOutput(data);
const output = formatInspectOutput(data);
assert.match(output, /=== GSD Database Inspect ===/, "contains header");
assert.match(output, /Schema version: 2/, "contains schema version");
assert.match(output, /Decisions:\s+12/, "contains decisions count");
assert.match(output, /Requirements:\s+8/, "contains requirements count");
assert.match(output, /Artifacts:\s+3/, "contains artifacts count");
assert.match(output, /Recent decisions:/, "contains recent decisions header");
assert.match(output, /D012: Use SQLite for persistence → node:sqlite with fallback/, "contains D012 entry");
assert.match(output, /D011: Markdown dual-write → DB-first then regenerate/, "contains D011 entry");
assert.match(output, /Recent requirements:/, "contains recent requirements header");
assert.match(output, /R015 \[active\]: Commands register via pi\.registerCommand/, "contains R015 entry");
assert.match(output, /R014 \[active\]: DB writes use upsert pattern/, "contains R014 entry");
});
assertMatch(output, /=== GSD Database Inspect ===/, "contains header");
assertMatch(output, /Schema version: 2/, "contains schema version");
assertMatch(output, /Decisions:\s+12/, "contains decisions count");
assertMatch(output, /Requirements:\s+8/, "contains requirements count");
assertMatch(output, /Artifacts:\s+3/, "contains artifacts count");
assertMatch(output, /Recent decisions:/, "contains recent decisions header");
assertMatch(output, /D012: Use SQLite for persistence → node:sqlite with fallback/, "contains D012 entry");
assertMatch(output, /D011: Markdown dual-write → DB-first then regenerate/, "contains D011 entry");
assertMatch(output, /Recent requirements:/, "contains recent requirements header");
assertMatch(output, /R015 \[active\]: Commands register via pi\.registerCommand/, "contains R015 entry");
assertMatch(output, /R014 \[active\]: DB writes use upsert pattern/, "contains R014 entry");
}
test('empty data', () => {
const data: InspectData = {
schemaVersion: 1,
counts: { decisions: 0, requirements: 0, artifacts: 0 },
recentDecisions: [],
recentRequirements: [],
};
// ── handles zero counts and no recent entries ──
console.log("# === gsd-inspect: empty data ===");
{
const data: InspectData = {
schemaVersion: 1,
counts: { decisions: 0, requirements: 0, artifacts: 0 },
recentDecisions: [],
recentRequirements: [],
};
const output = formatInspectOutput(data);
const output = formatInspectOutput(data);
assert.match(output, /Schema version: 1/, "contains schema version 1");
assert.match(output, /Decisions:\s+0/, "zero decisions");
assert.match(output, /Requirements:\s+0/, "zero requirements");
assert.match(output, /Artifacts:\s+0/, "zero artifacts");
assert.ok(!output.includes("Recent decisions:"), "no recent decisions section when empty");
assert.ok(!output.includes("Recent requirements:"), "no recent requirements section when empty");
});
assertMatch(output, /Schema version: 1/, "contains schema version 1");
assertMatch(output, /Decisions:\s+0/, "zero decisions");
assertMatch(output, /Requirements:\s+0/, "zero requirements");
assertMatch(output, /Artifacts:\s+0/, "zero artifacts");
assertTrue(!output.includes("Recent decisions:"), "no recent decisions section when empty");
assertTrue(!output.includes("Recent requirements:"), "no recent requirements section when empty");
}
test('null schema version', () => {
const data: InspectData = {
schemaVersion: null,
counts: { decisions: 0, requirements: 0, artifacts: 0 },
recentDecisions: [],
recentRequirements: [],
};
// ── handles null schema version ──
console.log("# === gsd-inspect: null schema version ===");
{
const data: InspectData = {
schemaVersion: null,
counts: { decisions: 0, requirements: 0, artifacts: 0 },
recentDecisions: [],
recentRequirements: [],
};
const output = formatInspectOutput(data);
assert.match(output, /Schema version: unknown/, "null version shows as unknown");
});
const output = formatInspectOutput(data);
assertMatch(output, /Schema version: unknown/, "null version shows as unknown");
}
test('five recent entries', () => {
const data: InspectData = {
schemaVersion: 2,
counts: { decisions: 5, requirements: 5, artifacts: 0 },
recentDecisions: [
{ id: "D005", decision: "Dec 5", choice: "C5" },
{ id: "D004", decision: "Dec 4", choice: "C4" },
{ id: "D003", decision: "Dec 3", choice: "C3" },
{ id: "D002", decision: "Dec 2", choice: "C2" },
{ id: "D001", decision: "Dec 1", choice: "C1" },
],
recentRequirements: [
{ id: "R005", status: "active", description: "Req 5" },
{ id: "R004", status: "done", description: "Req 4" },
{ id: "R003", status: "active", description: "Req 3" },
{ id: "R002", status: "active", description: "Req 2" },
{ id: "R001", status: "done", description: "Req 1" },
],
};
// ── formats up to 5 recent entries ──
console.log("# === gsd-inspect: five recent entries ===");
{
const data: InspectData = {
schemaVersion: 2,
counts: { decisions: 5, requirements: 5, artifacts: 0 },
recentDecisions: [
{ id: "D005", decision: "Dec 5", choice: "C5" },
{ id: "D004", decision: "Dec 4", choice: "C4" },
{ id: "D003", decision: "Dec 3", choice: "C3" },
{ id: "D002", decision: "Dec 2", choice: "C2" },
{ id: "D001", decision: "Dec 1", choice: "C1" },
],
recentRequirements: [
{ id: "R005", status: "active", description: "Req 5" },
{ id: "R004", status: "done", description: "Req 4" },
{ id: "R003", status: "active", description: "Req 3" },
{ id: "R002", status: "active", description: "Req 2" },
{ id: "R001", status: "done", description: "Req 1" },
],
};
const output = formatInspectOutput(data);
const output = formatInspectOutput(data);
for (let i = 1; i <= 5; i++) {
assert.match(output, new RegExp(`D00${i}: Dec ${i} → C${i}`), `contains D00${i}`);
}
for (let i = 1; i <= 5; i++) {
assert.match(output, new RegExp(`R00${i}`), `contains R00${i}`);
}
assert.match(output, /\[active\]/, "contains active status");
assert.match(output, /\[done\]/, "contains done status");
});
for (let i = 1; i <= 5; i++) {
assertMatch(output, new RegExp(`D00${i}: Dec ${i} → C${i}`), `contains D00${i}`);
}
for (let i = 1; i <= 5; i++) {
assertMatch(output, new RegExp(`R00${i}`), `contains R00${i}`);
}
assertMatch(output, /\[active\]/, "contains active status");
assertMatch(output, /\[done\]/, "contains done status");
}
test('output format', () => {
const data: InspectData = {
schemaVersion: 2,
counts: { decisions: 1, requirements: 1, artifacts: 0 },
recentDecisions: [{ id: "D001", decision: "Test", choice: "Yes" }],
recentRequirements: [{ id: "R001", status: "active", description: "Test req" }],
};
// ── output is multiline text (not JSON) ──
console.log("# === gsd-inspect: output format ===");
{
const data: InspectData = {
schemaVersion: 2,
counts: { decisions: 1, requirements: 1, artifacts: 0 },
recentDecisions: [{ id: "D001", decision: "Test", choice: "Yes" }],
recentRequirements: [{ id: "R001", status: "active", description: "Test req" }],
};
const output = formatInspectOutput(data);
const lines = output.split("\n");
assertTrue(lines.length > 5, "output has multiple lines");
assertTrue(!output.startsWith("{"), "output is not JSON");
}
report();
const output = formatInspectOutput(data);
const lines = output.split("\n");
assert.ok(lines.length > 5, "output has multiple lines");
assert.ok(!output.startsWith("{"), "output is not JSON");
});
});

View file

@ -1,3 +1,5 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
// gsd-recover.test.ts — Tests for the `gsd recover` recovery logic.
// Verifies: populate DB → clear hierarchy → recover from markdown → state matches.
@ -22,10 +24,6 @@ import {
} from '../gsd-db.ts';
import { migrateHierarchyToDb } from '../md-importer.ts';
import { deriveStateFromDb, invalidateStateCache } from '../state.ts';
import { createTestContext } from './test-helpers.ts';
const { assertEq, assertTrue, report } = createTestContext();
// ─── Fixture Helpers ───────────────────────────────────────────────────────
function createFixtureBase(): string {
@ -148,10 +146,8 @@ function clearHierarchyTables(): void {
// ─── Tests ────────────────────────────────────────────────────────────────
async function main() {
// ─── Test (a): Full recovery round-trip ─────────────────────────────────
console.log('\n=== recover: full round-trip (populate → clear → recover → verify) ===');
{
describe('gsd-recover', async () => {
test('full round-trip (populate, clear, recover, verify)', async () => {
const base = createFixtureBase();
try {
// Set up markdown fixtures
@ -163,14 +159,14 @@ async function main() {
// Step 1: Open DB and populate from markdown
openDatabase(':memory:');
const counts1 = migrateHierarchyToDb(base);
assertEq(counts1.milestones, 1, 'round-trip: initial migration 1 milestone');
assertEq(counts1.slices, 2, 'round-trip: initial migration 2 slices');
assertTrue(counts1.tasks >= 5, 'round-trip: initial migration at least 5 tasks');
assert.deepStrictEqual(counts1.milestones, 1, 'round-trip: initial migration - 1 milestone');
assert.deepStrictEqual(counts1.slices, 2, 'round-trip: initial migration - 2 slices');
assert.ok(counts1.tasks >= 5, 'round-trip: initial migration - at least 5 tasks');
// Step 2: Capture state from DB before clearing
invalidateStateCache();
const stateBefore = await deriveStateFromDb(base);
assertTrue(stateBefore.activeMilestone !== null, 'round-trip: state before has active milestone');
assert.ok(stateBefore.activeMilestone !== null, 'round-trip: state before has active milestone');
const milestonesBefore = getAllMilestones();
const slicesBefore = getMilestoneSlices('M001');
const s01TasksBefore = getSliceTasks('M001', 'S01');
@ -179,30 +175,30 @@ async function main() {
// Step 3: Clear hierarchy tables
clearHierarchyTables();
const milestonesAfterClear = getAllMilestones();
assertEq(milestonesAfterClear.length, 0, 'round-trip: milestones cleared');
assert.deepStrictEqual(milestonesAfterClear.length, 0, 'round-trip: milestones cleared');
// Step 4: Recover from markdown
const counts2 = migrateHierarchyToDb(base);
assertEq(counts2.milestones, counts1.milestones, 'round-trip: recovery milestone count matches');
assertEq(counts2.slices, counts1.slices, 'round-trip: recovery slice count matches');
assertEq(counts2.tasks, counts1.tasks, 'round-trip: recovery task count matches');
assert.deepStrictEqual(counts2.milestones, counts1.milestones, 'round-trip: recovery milestone count matches');
assert.deepStrictEqual(counts2.slices, counts1.slices, 'round-trip: recovery slice count matches');
assert.deepStrictEqual(counts2.tasks, counts1.tasks, 'round-trip: recovery task count matches');
// Step 5: Verify state matches
invalidateStateCache();
const stateAfter = await deriveStateFromDb(base);
assertEq(stateAfter.phase, stateBefore.phase, 'round-trip: phase matches');
assertEq(
assert.deepStrictEqual(stateAfter.phase, stateBefore.phase, 'round-trip: phase matches');
assert.deepStrictEqual(
stateAfter.activeMilestone?.id,
stateBefore.activeMilestone?.id,
'round-trip: active milestone ID matches',
);
assertEq(
assert.deepStrictEqual(
stateAfter.activeSlice?.id,
stateBefore.activeSlice?.id,
'round-trip: active slice ID matches',
);
assertEq(
assert.deepStrictEqual(
stateAfter.activeTask?.id,
stateBefore.activeTask?.id,
'round-trip: active task ID matches',
@ -210,32 +206,30 @@ async function main() {
// Verify row-level data matches
const milestonesAfter = getAllMilestones();
assertEq(milestonesAfter.length, milestonesBefore.length, 'round-trip: milestone row count');
assertEq(milestonesAfter[0]?.id, milestonesBefore[0]?.id, 'round-trip: milestone ID');
assertEq(milestonesAfter[0]?.title, milestonesBefore[0]?.title, 'round-trip: milestone title');
assert.deepStrictEqual(milestonesAfter.length, milestonesBefore.length, 'round-trip: milestone row count');
assert.deepStrictEqual(milestonesAfter[0]?.id, milestonesBefore[0]?.id, 'round-trip: milestone ID');
assert.deepStrictEqual(milestonesAfter[0]?.title, milestonesBefore[0]?.title, 'round-trip: milestone title');
const slicesAfter = getMilestoneSlices('M001');
assertEq(slicesAfter.length, slicesBefore.length, 'round-trip: slice row count');
assertEq(slicesAfter[0]?.id, slicesBefore[0]?.id, 'round-trip: S01 ID');
assertEq(slicesAfter[0]?.status, slicesBefore[0]?.status, 'round-trip: S01 status');
assertEq(slicesAfter[1]?.id, slicesBefore[1]?.id, 'round-trip: S02 ID');
assert.deepStrictEqual(slicesAfter.length, slicesBefore.length, 'round-trip: slice row count');
assert.deepStrictEqual(slicesAfter[0]?.id, slicesBefore[0]?.id, 'round-trip: S01 ID');
assert.deepStrictEqual(slicesAfter[0]?.status, slicesBefore[0]?.status, 'round-trip: S01 status');
assert.deepStrictEqual(slicesAfter[1]?.id, slicesBefore[1]?.id, 'round-trip: S02 ID');
const s01TasksAfter = getSliceTasks('M001', 'S01');
assertEq(s01TasksAfter.length, s01TasksBefore.length, 'round-trip: S01 task count');
assert.deepStrictEqual(s01TasksAfter.length, s01TasksBefore.length, 'round-trip: S01 task count');
const s02TasksAfter = getSliceTasks('M001', 'S02');
assertEq(s02TasksAfter.length, s02TasksBefore.length, 'round-trip: S02 task count');
assert.deepStrictEqual(s02TasksAfter.length, s02TasksBefore.length, 'round-trip: S02 task count');
closeDatabase();
} finally {
closeDatabase();
cleanup(base);
}
}
});
// ─── Test (a2): v8 planning columns populated after recovery ───────────
console.log('\n=== recover: v8 planning columns populated ===');
{
test('v8 planning columns populated', async () => {
const base = createFixtureBase();
try {
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_M001);
@ -248,75 +242,70 @@ async function main() {
// Milestone planning columns
const milestone = getMilestone('M001');
assertTrue(milestone !== null, 'v8: milestone exists');
assertEq(milestone!.vision, 'Test recovery round-trip.', 'v8: milestone vision populated');
assertTrue(milestone!.success_criteria.length >= 2, 'v8: milestone success_criteria has entries');
assertEq(milestone!.success_criteria[0], 'All recovery tests pass', 'v8: first success criterion');
assertTrue(milestone!.boundary_map_markdown.includes('Boundary Map'), 'v8: boundary_map_markdown populated');
assertTrue(milestone!.boundary_map_markdown.includes('S01'), 'v8: boundary_map_markdown has S01');
assert.ok(milestone !== null, 'v8: milestone exists');
assert.deepStrictEqual(milestone!.vision, 'Test recovery round-trip.', 'v8: milestone vision populated');
assert.ok(milestone!.success_criteria.length >= 2, 'v8: milestone success_criteria has entries');
assert.deepStrictEqual(milestone!.success_criteria[0], 'All recovery tests pass', 'v8: first success criterion');
assert.ok(milestone!.boundary_map_markdown.includes('Boundary Map'), 'v8: boundary_map_markdown populated');
assert.ok(milestone!.boundary_map_markdown.includes('S01'), 'v8: boundary_map_markdown has S01');
// Tool-only fields left empty per D004
assertEq(milestone!.key_risks.length, 0, 'v8: key_risks left empty (tool-only per D004)');
assertEq(milestone!.requirement_coverage, '', 'v8: requirement_coverage left empty (tool-only per D004)');
assert.deepStrictEqual(milestone!.key_risks.length, 0, 'v8: key_risks left empty (tool-only per D004)');
assert.deepStrictEqual(milestone!.requirement_coverage, '', 'v8: requirement_coverage left empty (tool-only per D004)');
// Slice planning columns
const sliceS01 = getSlice('M001', 'S01');
assertTrue(sliceS01 !== null, 'v8: slice S01 exists');
assertEq(sliceS01!.goal, 'Setup fixtures.', 'v8: S01 goal populated');
assert.ok(sliceS01 !== null, 'v8: slice S01 exists');
assert.deepStrictEqual(sliceS01!.goal, 'Setup fixtures.', 'v8: S01 goal populated');
const sliceS02 = getSlice('M001', 'S02');
assertTrue(sliceS02 !== null, 'v8: slice S02 exists');
assertEq(sliceS02!.goal, 'Build core.', 'v8: S02 goal populated');
assert.ok(sliceS02 !== null, 'v8: slice S02 exists');
assert.deepStrictEqual(sliceS02!.goal, 'Build core.', 'v8: S02 goal populated');
// Slice tool-only fields left empty per D004
assertEq(sliceS01!.proof_level, '', 'v8: S01 proof_level left empty (tool-only per D004)');
assert.deepStrictEqual(sliceS01!.proof_level, '', 'v8: S01 proof_level left empty (tool-only per D004)');
// Task planning columns S01/T01
// Task planning columns - S01/T01
const taskS01T01 = getTask('M001', 'S01', 'T01');
assertTrue(taskS01T01 !== null, 'v8: task S01/T01 exists');
assertTrue(taskS01T01!.files.length >= 2, 'v8: S01/T01 files populated');
assertTrue(taskS01T01!.files.includes('init.ts'), 'v8: S01/T01 files includes init.ts');
assertTrue(taskS01T01!.files.includes('config.ts'), 'v8: S01/T01 files includes config.ts');
assertEq(taskS01T01!.verify, '`node test-init.ts`', 'v8: S01/T01 verify populated');
assert.ok(taskS01T01 !== null, 'v8: task S01/T01 exists');
assert.ok(taskS01T01!.files.length >= 2, 'v8: S01/T01 files populated');
assert.ok(taskS01T01!.files.includes('init.ts'), 'v8: S01/T01 files includes init.ts');
assert.ok(taskS01T01!.files.includes('config.ts'), 'v8: S01/T01 files includes config.ts');
assert.deepStrictEqual(taskS01T01!.verify, '`node test-init.ts`', 'v8: S01/T01 verify populated');
// Task planning columns S02/T02
// Task planning columns - S02/T02
const taskS02T02 = getTask('M001', 'S02', 'T02');
assertTrue(taskS02T02 !== null, 'v8: task S02/T02 exists');
assertTrue(taskS02T02!.files.length >= 2, 'v8: S02/T02 files populated');
assertTrue(taskS02T02!.files.includes('test-core.ts'), 'v8: S02/T02 files includes test-core.ts');
assertEq(taskS02T02!.verify, '`npm test`', 'v8: S02/T02 verify populated');
assert.ok(taskS02T02 !== null, 'v8: task S02/T02 exists');
assert.ok(taskS02T02!.files.length >= 2, 'v8: S02/T02 files populated');
assert.ok(taskS02T02!.files.includes('test-core.ts'), 'v8: S02/T02 files includes test-core.ts');
assert.deepStrictEqual(taskS02T02!.verify, '`npm test`', 'v8: S02/T02 verify populated');
// Task with no Files/Verify — not applicable since all fixtures now have them,
// but confirm a task from S02 has correct data
const taskS02T03 = getTask('M001', 'S02', 'T03');
assertTrue(taskS02T03 !== null, 'v8: task S02/T03 exists');
assertTrue(taskS02T03!.files.includes('polish.ts'), 'v8: S02/T03 files includes polish.ts');
assertEq(taskS02T03!.verify, '`node test-polish.ts`', 'v8: S02/T03 verify populated');
assert.ok(taskS02T03 !== null, 'v8: task S02/T03 exists');
assert.ok(taskS02T03!.files.includes('polish.ts'), 'v8: S02/T03 files includes polish.ts');
assert.deepStrictEqual(taskS02T03!.verify, '`node test-polish.ts`', 'v8: S02/T03 verify populated');
// Diagnostic: v8 planning columns queryable via SQL
const db = _getAdapter()!;
const milestoneRow = db.prepare("SELECT vision, success_criteria, boundary_map_markdown FROM milestones WHERE id = 'M001'").get() as any;
assertTrue(milestoneRow.vision.length > 0, 'v8-diag: vision column queryable');
assertTrue(milestoneRow.boundary_map_markdown.length > 0, 'v8-diag: boundary_map_markdown column queryable');
assert.ok(milestoneRow.vision.length > 0, 'v8-diag: vision column queryable');
assert.ok(milestoneRow.boundary_map_markdown.length > 0, 'v8-diag: boundary_map_markdown column queryable');
const sliceRow = db.prepare("SELECT goal FROM slices WHERE milestone_id = 'M001' AND id = 'S01'").get() as any;
assertTrue(sliceRow.goal.length > 0, 'v8-diag: goal column queryable');
assert.ok(sliceRow.goal.length > 0, 'v8-diag: goal column queryable');
const taskRow = db.prepare("SELECT files, verify FROM tasks WHERE milestone_id = 'M001' AND slice_id = 'S01' AND id = 'T01'").get() as any;
assertTrue(taskRow.files.length > 2, 'v8-diag: files column queryable (JSON array)');
assertTrue(taskRow.verify.length > 0, 'v8-diag: verify column queryable');
assert.ok(taskRow.files.length > 2, 'v8-diag: files column queryable (JSON array)');
assert.ok(taskRow.verify.length > 0, 'v8-diag: verify column queryable');
closeDatabase();
} finally {
closeDatabase();
cleanup(base);
}
}
});
// ─── Test (b): Idempotent recovery — double recover ────────────────────
console.log('\n=== recover: idempotent — double recovery produces same state ===');
{
test('idempotent - double recovery produces same state', async () => {
const base = createFixtureBase();
try {
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_M001);
@ -337,18 +326,18 @@ async function main() {
invalidateStateCache();
const state2 = await deriveStateFromDb(base);
assertEq(state2.phase, state1.phase, 'idempotent: phase matches');
assertEq(
assert.deepStrictEqual(state2.phase, state1.phase, 'idempotent: phase matches');
assert.deepStrictEqual(
state2.activeMilestone?.id,
state1.activeMilestone?.id,
'idempotent: active milestone matches',
);
assertEq(
assert.deepStrictEqual(
state2.activeSlice?.id,
state1.activeSlice?.id,
'idempotent: active slice matches',
);
assertEq(
assert.deepStrictEqual(
state2.activeTask?.id,
state1.activeTask?.id,
'idempotent: active task matches',
@ -359,11 +348,9 @@ async function main() {
closeDatabase();
cleanup(base);
}
}
});
// ─── Test (c): Recovery preserves non-hierarchy data ───────────────────
console.log('\n=== recover: preserves decisions/requirements ===');
{
test('preserves decisions/requirements', async () => {
const base = createFixtureBase();
try {
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_M001);
@ -402,35 +389,33 @@ async function main() {
// Verify decisions and requirements survived
const decisions = db.prepare('SELECT * FROM decisions').all();
assertEq(decisions.length, 1, 'preserve: decision survives clear');
assertEq((decisions[0] as any).id, 'D001', 'preserve: decision ID intact');
assert.deepStrictEqual(decisions.length, 1, 'preserve: decision survives clear');
assert.deepStrictEqual((decisions[0] as any).id, 'D001', 'preserve: decision ID intact');
const requirements = db.prepare('SELECT * FROM requirements').all();
assertEq(requirements.length, 1, 'preserve: requirement survives clear');
assertEq((requirements[0] as any).id, 'R001', 'preserve: requirement ID intact');
assert.deepStrictEqual(requirements.length, 1, 'preserve: requirement survives clear');
assert.deepStrictEqual((requirements[0] as any).id, 'R001', 'preserve: requirement ID intact');
// Recover hierarchy
migrateHierarchyToDb(base);
const milestones = getAllMilestones();
assertTrue(milestones.length > 0, 'preserve: milestones recovered after clear');
assert.ok(milestones.length > 0, 'preserve: milestones recovered after clear');
// Verify non-hierarchy data still intact after recovery
const decisionsAfter = db.prepare('SELECT * FROM decisions').all();
assertEq(decisionsAfter.length, 1, 'preserve: decision still present after recovery');
assert.deepStrictEqual(decisionsAfter.length, 1, 'preserve: decision still present after recovery');
closeDatabase();
} finally {
closeDatabase();
cleanup(base);
}
}
});
// ─── Test (d): Recovery from empty markdown dir ────────────────────────
console.log('\n=== recover: empty milestones dir ===');
{
test('empty milestones dir', async () => {
const base = createFixtureBase();
try {
// No milestones written just the empty dir
// No milestones written - just the empty dir
openDatabase(':memory:');
// Pre-populate to simulate existing state
@ -439,24 +424,17 @@ async function main() {
// Clear and recover from empty
clearHierarchyTables();
const counts = migrateHierarchyToDb(base);
assertEq(counts.milestones, 0, 'empty: zero milestones recovered');
assertEq(counts.slices, 0, 'empty: zero slices recovered');
assertEq(counts.tasks, 0, 'empty: zero tasks recovered');
assert.deepStrictEqual(counts.milestones, 0, 'empty: zero milestones recovered');
assert.deepStrictEqual(counts.slices, 0, 'empty: zero slices recovered');
assert.deepStrictEqual(counts.tasks, 0, 'empty: zero tasks recovered');
const all = getAllMilestones();
assertEq(all.length, 0, 'empty: no milestones in DB after recovery');
assert.deepStrictEqual(all.length, 0, 'empty: no milestones in DB after recovery');
closeDatabase();
} finally {
closeDatabase();
cleanup(base);
}
}
report();
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
});

View file

@ -1,9 +1,10 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
// gsd-tools — Structured LLM tool tests
//
// Tests the three registered tools: gsd_decision_save, gsd_requirement_update, gsd_summary_save.
// Each tool is tested via direct function invocation against an in-memory DB.
import { createTestContext } from './test-helpers.ts';
import * as path from 'node:path';
import * as os from 'node:os';
import * as fs from 'node:fs';
@ -25,8 +26,6 @@ import {
} from '../db-writer.ts';
import type { Requirement } from '../types.ts';
const { assertEq, assertTrue, assertMatch, report } = createTestContext();
// ═══════════════════════════════════════════════════════════════════════════
// Helpers
// ═══════════════════════════════════════════════════════════════════════════
@ -46,281 +45,249 @@ function cleanupDir(dir: string): void {
/**
* Simulate tool execute by calling the underlying DB functions directly.
* The actual tool registration happens in index.ts; here we test the
* execute logic pattern: check DB call writer return result.
* execute logic pattern: check DB -> call writer -> return result.
*/
// ═══════════════════════════════════════════════════════════════════════════
// gsd_decision_save tool tests
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n── gsd_decision_save ──');
{
const tmpDir = makeTmpDir();
try {
const dbPath = path.join(tmpDir, '.gsd', 'gsd.db');
openDatabase(dbPath);
assertTrue(isDbAvailable(), 'DB should be available after open');
// (a) Decision tool creates DB row + returns new ID
const result = await saveDecisionToDb(
{
scope: 'architecture',
decision: 'Use SQLite for metadata',
choice: 'SQLite',
rationale: 'Sync API fits the CLI model',
revisable: 'Yes',
when_context: 'M001',
},
tmpDir,
);
assertEq(result.id, 'D001', 'First decision should be D001');
// Verify DB row exists
const row = getDecisionById('D001');
assertTrue(row !== null, 'Decision D001 should exist in DB');
assertEq(row!.scope, 'architecture', 'Decision scope should match');
assertEq(row!.decision, 'Use SQLite for metadata', 'Decision text should match');
assertEq(row!.choice, 'SQLite', 'Decision choice should match');
// Verify DECISIONS.md was generated
const mdPath = path.join(tmpDir, '.gsd', 'DECISIONS.md');
assertTrue(fs.existsSync(mdPath), 'DECISIONS.md should be created');
const mdContent = fs.readFileSync(mdPath, 'utf-8');
assertTrue(mdContent.includes('D001'), 'DECISIONS.md should contain D001');
assertTrue(mdContent.includes('SQLite'), 'DECISIONS.md should contain choice');
// (e) Decision tool auto-assigns correct next ID
const result2 = await saveDecisionToDb(
{
scope: 'testing',
decision: 'Test runner',
choice: 'vitest',
rationale: 'Fast and ESM-native',
},
tmpDir,
);
assertEq(result2.id, 'D002', 'Second decision should be D002');
const result3 = await saveDecisionToDb(
{
scope: 'CI',
decision: 'CI platform',
choice: 'GitHub Actions',
rationale: 'Integrated with repo',
},
tmpDir,
);
assertEq(result3.id, 'D003', 'Third decision should be D003');
closeDatabase();
} finally {
cleanupDir(tmpDir);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// gsd_requirement_update tool tests
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n── gsd_requirement_update ──');
{
const tmpDir = makeTmpDir();
try {
const dbPath = path.join(tmpDir, '.gsd', 'gsd.db');
openDatabase(dbPath);
// Seed a requirement
const seedReq: Requirement = {
id: 'R001',
class: 'functional',
status: 'active',
description: 'Must support SQLite storage',
why: 'Structured data needs',
source: 'design',
primary_owner: 'S03',
supporting_slices: '',
validation: '',
notes: '',
full_content: '',
superseded_by: null,
};
upsertRequirement(seedReq);
// (b) Requirement update tool modifies existing requirement
await updateRequirementInDb(
'R001',
{ status: 'validated', validation: 'Unit tests pass', notes: 'Verified in S06' },
tmpDir,
);
const updated = getRequirementById('R001');
assertTrue(updated !== null, 'R001 should still exist');
assertEq(updated!.status, 'validated', 'Status should be updated');
assertEq(updated!.validation, 'Unit tests pass', 'Validation should be updated');
assertEq(updated!.notes, 'Verified in S06', 'Notes should be updated');
// Original fields preserved
assertEq(updated!.description, 'Must support SQLite storage', 'Description should be preserved');
assertEq(updated!.primary_owner, 'S03', 'Primary owner should be preserved');
// Verify REQUIREMENTS.md was generated
const mdPath = path.join(tmpDir, '.gsd', 'REQUIREMENTS.md');
assertTrue(fs.existsSync(mdPath), 'REQUIREMENTS.md should be created');
const mdContent = fs.readFileSync(mdPath, 'utf-8');
assertTrue(mdContent.includes('R001'), 'REQUIREMENTS.md should contain R001');
assertTrue(mdContent.includes('validated'), 'REQUIREMENTS.md should reflect updated status');
// Updating non-existent requirement throws
let threwForMissing = false;
describe('gsd-tools', () => {
test('gsd_decision_save', async () => {
const tmpDir = makeTmpDir();
try {
await updateRequirementInDb('R999', { status: 'deferred' }, tmpDir);
} catch (err) {
threwForMissing = true;
assertTrue(
(err as Error).message.includes('R999'),
'Error should mention the missing requirement ID',
const dbPath = path.join(tmpDir, '.gsd', 'gsd.db');
openDatabase(dbPath);
assert.ok(isDbAvailable(), 'DB should be available after open');
// (a) Decision tool creates DB row + returns new ID
const result = await saveDecisionToDb(
{
scope: 'architecture',
decision: 'Use SQLite for metadata',
choice: 'SQLite',
rationale: 'Sync API fits the CLI model',
revisable: 'Yes',
when_context: 'M001',
},
tmpDir,
);
assert.deepStrictEqual(result.id, 'D001', 'First decision should be D001');
// Verify DB row exists
const row = getDecisionById('D001');
assert.ok(row !== null, 'Decision D001 should exist in DB');
assert.deepStrictEqual(row!.scope, 'architecture', 'Decision scope should match');
assert.deepStrictEqual(row!.decision, 'Use SQLite for metadata', 'Decision text should match');
assert.deepStrictEqual(row!.choice, 'SQLite', 'Decision choice should match');
// Verify DECISIONS.md was generated
const mdPath = path.join(tmpDir, '.gsd', 'DECISIONS.md');
assert.ok(fs.existsSync(mdPath), 'DECISIONS.md should be created');
const mdContent = fs.readFileSync(mdPath, 'utf-8');
assert.ok(mdContent.includes('D001'), 'DECISIONS.md should contain D001');
assert.ok(mdContent.includes('SQLite'), 'DECISIONS.md should contain choice');
// (e) Decision tool auto-assigns correct next ID
const result2 = await saveDecisionToDb(
{
scope: 'testing',
decision: 'Test runner',
choice: 'vitest',
rationale: 'Fast and ESM-native',
},
tmpDir,
);
assert.deepStrictEqual(result2.id, 'D002', 'Second decision should be D002');
const result3 = await saveDecisionToDb(
{
scope: 'CI',
decision: 'CI platform',
choice: 'GitHub Actions',
rationale: 'Integrated with repo',
},
tmpDir,
);
assert.deepStrictEqual(result3.id, 'D003', 'Third decision should be D003');
closeDatabase();
} finally {
cleanupDir(tmpDir);
}
assertTrue(threwForMissing, 'Should throw for non-existent requirement');
});
closeDatabase();
} finally {
cleanupDir(tmpDir);
}
}
test('gsd_requirement_update', async () => {
const tmpDir = makeTmpDir();
try {
const dbPath = path.join(tmpDir, '.gsd', 'gsd.db');
openDatabase(dbPath);
// ═══════════════════════════════════════════════════════════════════════════
// gsd_summary_save tool tests
// ═══════════════════════════════════════════════════════════════════════════
// Seed a requirement
const seedReq: Requirement = {
id: 'R001',
class: 'functional',
status: 'active',
description: 'Must support SQLite storage',
why: 'Structured data needs',
source: 'design',
primary_owner: 'S03',
supporting_slices: '',
validation: '',
notes: '',
full_content: '',
superseded_by: null,
};
upsertRequirement(seedReq);
console.log('\n── gsd_summary_save ──');
// (b) Requirement update tool modifies existing requirement
await updateRequirementInDb(
'R001',
{ status: 'validated', validation: 'Unit tests pass', notes: 'Verified in S06' },
tmpDir,
);
{
const tmpDir = makeTmpDir();
try {
const dbPath = path.join(tmpDir, '.gsd', 'gsd.db');
openDatabase(dbPath);
const updated = getRequirementById('R001');
assert.ok(updated !== null, 'R001 should still exist');
assert.deepStrictEqual(updated!.status, 'validated', 'Status should be updated');
assert.deepStrictEqual(updated!.validation, 'Unit tests pass', 'Validation should be updated');
assert.deepStrictEqual(updated!.notes, 'Verified in S06', 'Notes should be updated');
// Original fields preserved
assert.deepStrictEqual(updated!.description, 'Must support SQLite storage', 'Description should be preserved');
assert.deepStrictEqual(updated!.primary_owner, 'S03', 'Primary owner should be preserved');
// (c) Summary tool creates artifact row
await saveArtifactToDb(
{
path: 'milestones/M001/slices/S01/S01-SUMMARY.md',
artifact_type: 'SUMMARY',
content: '# S01 Summary\n\nThis is a test summary.',
milestone_id: 'M001',
slice_id: 'S01',
},
tmpDir,
);
// Verify REQUIREMENTS.md was generated
const mdPath = path.join(tmpDir, '.gsd', 'REQUIREMENTS.md');
assert.ok(fs.existsSync(mdPath), 'REQUIREMENTS.md should be created');
const mdContent = fs.readFileSync(mdPath, 'utf-8');
assert.ok(mdContent.includes('R001'), 'REQUIREMENTS.md should contain R001');
assert.ok(mdContent.includes('validated'), 'REQUIREMENTS.md should reflect updated status');
// Verify artifact in DB
const adapter = _getAdapter();
assertTrue(adapter !== null, 'Adapter should be available');
const rows = adapter!.prepare(
"SELECT * FROM artifacts WHERE path = 'milestones/M001/slices/S01/S01-SUMMARY.md'",
).all();
assertEq(rows.length, 1, 'Should have 1 artifact row');
assertEq(rows[0]['artifact_type'] as string, 'SUMMARY', 'Artifact type should be SUMMARY');
assertEq(rows[0]['milestone_id'] as string, 'M001', 'Milestone ID should match');
assertEq(rows[0]['slice_id'] as string, 'S01', 'Slice ID should match');
// Updating non-existent requirement throws
let threwForMissing = false;
try {
await updateRequirementInDb('R999', { status: 'deferred' }, tmpDir);
} catch (err) {
threwForMissing = true;
assert.ok(
(err as Error).message.includes('R999'),
'Error should mention the missing requirement ID',
);
}
assert.ok(threwForMissing, 'Should throw for non-existent requirement');
// Verify file was written to disk
const filePath = path.join(tmpDir, '.gsd', 'milestones', 'M001', 'slices', 'S01', 'S01-SUMMARY.md');
assertTrue(fs.existsSync(filePath), 'Summary file should be written to disk');
const fileContent = fs.readFileSync(filePath, 'utf-8');
assertTrue(fileContent.includes('S01 Summary'), 'File should contain summary content');
closeDatabase();
} finally {
cleanupDir(tmpDir);
}
});
// Test milestone-level artifact (no slice_id)
await saveArtifactToDb(
{
path: 'milestones/M001/M001-CONTEXT.md',
artifact_type: 'CONTEXT',
content: '# M001 Context\n\nContext notes.',
milestone_id: 'M001',
},
tmpDir,
);
test('gsd_summary_save', async () => {
const tmpDir = makeTmpDir();
try {
const dbPath = path.join(tmpDir, '.gsd', 'gsd.db');
openDatabase(dbPath);
const mFilePath = path.join(tmpDir, '.gsd', 'milestones', 'M001', 'M001-CONTEXT.md');
assertTrue(fs.existsSync(mFilePath), 'Milestone-level artifact file should be created');
// (c) Summary tool creates artifact row
await saveArtifactToDb(
{
path: 'milestones/M001/slices/S01/S01-SUMMARY.md',
artifact_type: 'SUMMARY',
content: '# S01 Summary\n\nThis is a test summary.',
milestone_id: 'M001',
slice_id: 'S01',
},
tmpDir,
);
// Test task-level artifact
await saveArtifactToDb(
{
path: 'milestones/M001/slices/S01/tasks/T01-SUMMARY.md',
artifact_type: 'SUMMARY',
content: '# T01 Summary\n\nTask summary.',
milestone_id: 'M001',
slice_id: 'S01',
task_id: 'T01',
},
tmpDir,
);
// Verify artifact in DB
const adapter = _getAdapter();
assert.ok(adapter !== null, 'Adapter should be available');
const rows = adapter!.prepare(
"SELECT * FROM artifacts WHERE path = 'milestones/M001/slices/S01/S01-SUMMARY.md'",
).all();
assert.deepStrictEqual(rows.length, 1, 'Should have 1 artifact row');
assert.deepStrictEqual(rows[0]['artifact_type'] as string, 'SUMMARY', 'Artifact type should be SUMMARY');
assert.deepStrictEqual(rows[0]['milestone_id'] as string, 'M001', 'Milestone ID should match');
assert.deepStrictEqual(rows[0]['slice_id'] as string, 'S01', 'Slice ID should match');
const tFilePath = path.join(tmpDir, '.gsd', 'milestones', 'M001', 'slices', 'S01', 'tasks', 'T01-SUMMARY.md');
assertTrue(fs.existsSync(tFilePath), 'Task-level artifact file should be created');
// Verify file was written to disk
const filePath = path.join(tmpDir, '.gsd', 'milestones', 'M001', 'slices', 'S01', 'S01-SUMMARY.md');
assert.ok(fs.existsSync(filePath), 'Summary file should be written to disk');
const fileContent = fs.readFileSync(filePath, 'utf-8');
assert.ok(fileContent.includes('S01 Summary'), 'File should contain summary content');
closeDatabase();
} finally {
cleanupDir(tmpDir);
}
}
// Test milestone-level artifact (no slice_id)
await saveArtifactToDb(
{
path: 'milestones/M001/M001-CONTEXT.md',
artifact_type: 'CONTEXT',
content: '# M001 Context\n\nContext notes.',
milestone_id: 'M001',
},
tmpDir,
);
// ═══════════════════════════════════════════════════════════════════════════
// DB unavailable error paths
// ═══════════════════════════════════════════════════════════════════════════
const mFilePath = path.join(tmpDir, '.gsd', 'milestones', 'M001', 'M001-CONTEXT.md');
assert.ok(fs.existsSync(mFilePath), 'Milestone-level artifact file should be created');
console.log('\n── DB unavailable error paths ──');
// Test task-level artifact
await saveArtifactToDb(
{
path: 'milestones/M001/slices/S01/tasks/T01-SUMMARY.md',
artifact_type: 'SUMMARY',
content: '# T01 Summary\n\nTask summary.',
milestone_id: 'M001',
slice_id: 'S01',
task_id: 'T01',
},
tmpDir,
);
{
// (d) All tools return isError when DB unavailable
// Close any open DB and don't open a new one
try { closeDatabase(); } catch { /* already closed */ }
const tFilePath = path.join(tmpDir, '.gsd', 'milestones', 'M001', 'slices', 'S01', 'tasks', 'T01-SUMMARY.md');
assert.ok(fs.existsSync(tFilePath), 'Task-level artifact file should be created');
// isDbAvailable() should return false
assertTrue(!isDbAvailable(), 'DB should be unavailable after close');
closeDatabase();
} finally {
cleanupDir(tmpDir);
}
});
// nextDecisionId degrades gracefully
const fallbackId = await nextDecisionId();
assertEq(fallbackId, 'D001', 'nextDecisionId should return D001 when DB unavailable');
}
test('DB unavailable error paths', async () => {
// (d) All tools return isError when DB unavailable
// Close any open DB and don't open a new one
try { closeDatabase(); } catch { /* already closed */ }
// ═══════════════════════════════════════════════════════════════════════════
// Tool result format verification
// ═══════════════════════════════════════════════════════════════════════════
// isDbAvailable() should return false
assert.ok(!isDbAvailable(), 'DB should be unavailable after close');
console.log('\n── Tool result format ──');
// nextDecisionId degrades gracefully
const fallbackId = await nextDecisionId();
assert.deepStrictEqual(fallbackId, 'D001', 'nextDecisionId should return D001 when DB unavailable');
});
{
const tmpDir = makeTmpDir();
try {
const dbPath = path.join(tmpDir, '.gsd', 'gsd.db');
openDatabase(dbPath);
test('Tool result format', async () => {
const tmpDir = makeTmpDir();
try {
const dbPath = path.join(tmpDir, '.gsd', 'gsd.db');
openDatabase(dbPath);
// Verify result follows AgentToolResult interface: {content: [{type: "text", text}], details}
const result = await saveDecisionToDb(
{
scope: 'format-test',
decision: 'Test format',
choice: 'TypeBox',
rationale: 'Schema validation',
},
tmpDir,
);
// Verify result follows AgentToolResult interface: {content: [{type: "text", text}], details}
const result = await saveDecisionToDb(
{
scope: 'format-test',
decision: 'Test format',
choice: 'TypeBox',
rationale: 'Schema validation',
},
tmpDir,
);
// The saveDecisionToDb returns {id} — the tool wrapping adds the AgentToolResult shape.
// Verify the raw function returns the expected shape.
assertTrue(typeof result.id === 'string', 'saveDecisionToDb should return {id: string}');
assertMatch(result.id, /^D\d{3}$/, 'ID should match DXXX pattern');
// The saveDecisionToDb returns {id} - the tool wrapping adds the AgentToolResult shape.
// Verify the raw function returns the expected shape.
assert.ok(typeof result.id === 'string', 'saveDecisionToDb should return {id: string}');
assert.match(result.id, /^D\d{3}$/, 'ID should match DXXX pattern');
closeDatabase();
} finally {
cleanupDir(tmpDir);
}
}
// ═══════════════════════════════════════════════════════════════════════════
report();
closeDatabase();
} finally {
cleanupDir(tmpDir);
}
});
});