refactor: migrate D-G test files from createTestContext to node:test (#2418)
This commit is contained in:
parent
e4d21c40d0
commit
b24594d79f
23 changed files with 2928 additions and 3479 deletions
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 ──────────────────────────────────────────────────────────
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue